mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
@@ -0,0 +1,18 @@
|
||||
# CodeQL configuration
|
||||
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
|
||||
|
||||
name: "JavaScript/TypeScript Security Analysis"
|
||||
|
||||
disable-default-queries: false
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
- uses: security-and-quality
|
||||
|
||||
# Paths to exclude from analysis
|
||||
paths-ignore:
|
||||
- "**/node_modules/**"
|
||||
# The minified es-module-shims distribution currently triggers findings in
|
||||
# third-party code. Keep this exception file-specific so Preact, HTM, and
|
||||
# future files under static/lib remain covered.
|
||||
- "pkg/service/soundtouchweb/static/lib/es-module-shims.js"
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Browser Compatibility Tests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-browser:
|
||||
name: Player browser tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Verify a Chrome/Chromium binary is available
|
||||
# chromedp (used by the browsertest-tagged tests) discovers Chrome on
|
||||
# PATH or in a standard install location; GitHub's ubuntu-latest
|
||||
# runner image ships Google Chrome preinstalled. Fail fast with a
|
||||
# clear message here instead of a cryptic chromedp allocator error
|
||||
# if that image ever stops including it.
|
||||
run: google-chrome --version
|
||||
|
||||
- name: Run browser-level player compatibility tests
|
||||
run: make test-browser
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Set build date
|
||||
id: build_date
|
||||
|
||||
@@ -40,17 +40,17 @@ jobs:
|
||||
run: sudo apt-get install -y libpcap-dev
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
|
||||
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || matrix.language == 'javascript-typescript' && './.github/codeql-config-js.yml' || '' }}
|
||||
|
||||
- name: Build Go (required for manual build-mode)
|
||||
if: matrix.language == 'go'
|
||||
run: go build ./...
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -514,7 +514,7 @@ jobs:
|
||||
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Upload Semgrep SARIF results
|
||||
if: always()
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
sarif_file: semgrep.sarif
|
||||
continue-on-error: true
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# golangci-lint configuration for Bose SoundTouch Go Library
|
||||
# Compatible with golangci-lint v2.8.0
|
||||
# Compatible with golangci-lint v2.13.1
|
||||
# See: https://golangci-lint.run/usage/configuration/
|
||||
|
||||
version: "2"
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.6-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
|
||||
.PHONY: all build build-cli test test-coverage test-browser test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
|
||||
|
||||
# Load .env if present (simple KEY=VALUE format, no shell quoting)
|
||||
-include .env
|
||||
@@ -147,6 +147,15 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
# Browser-level regression tests for the embedded player's static assets
|
||||
# (see pkg/service/soundtouchweb/browser_compatibility_test.go). Opt-in via
|
||||
# the "browsertest" build tag, not part of `test`/`check`, since they need a
|
||||
# Chrome/Chromium binary that chromedp can find on PATH or in a standard
|
||||
# install location.
|
||||
test-browser:
|
||||
@echo "Running browser-level compatibility tests..."
|
||||
$(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/...
|
||||
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
# Archive any existing tests/integration/testdata/ to a timestamped sibling
|
||||
@@ -243,7 +252,7 @@ vet:
|
||||
|
||||
lint:
|
||||
@echo "Running golangci-lint..."
|
||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest" && exit 1)
|
||||
@which golangci-lint > /dev/null || (echo "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1)
|
||||
golangci-lint run
|
||||
|
||||
tidy:
|
||||
@@ -501,6 +510,7 @@ help:
|
||||
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
|
||||
@echo " test - Run tests"
|
||||
@echo " test-coverage - Run tests with coverage report"
|
||||
@echo " test-browser - Run browser-level (chromedp) player compatibility tests"
|
||||
@echo " test-http-client - Run .http integration tests via Docker Compose"
|
||||
@echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)"
|
||||
@echo " check - Run fmt, vet, and tests"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// sanitizeLog strips newline characters from s to prevent log-injection
|
||||
// (CodeQL go/log-injection). Values from HTTP requests may contain
|
||||
// attacker-controlled newlines.
|
||||
func sanitizeLog(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\r", `\r`)
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -654,8 +654,8 @@ func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
browseAttrs = []any{
|
||||
"objectID", between(string(body), "<ObjectID>", "</ObjectID>"),
|
||||
"browseFlag", between(string(body), "<BrowseFlag>", "</BrowseFlag>"),
|
||||
"objectID", sanitizeLog(between(string(body), "<ObjectID>", "</ObjectID>")),
|
||||
"browseFlag", sanitizeLog(between(string(body), "<BrowseFlag>", "</BrowseFlag>")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,7 +664,7 @@ func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
|
||||
attrs := []any{
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"path", sanitizeLog(r.URL.Path),
|
||||
"status", rec.status,
|
||||
"bytes", rec.bytes,
|
||||
"from", r.RemoteAddr,
|
||||
|
||||
@@ -88,7 +88,7 @@ go build -o soundtouch-player
|
||||
./soundtouch-player -port 8888
|
||||
|
||||
# Connect to specific device
|
||||
./soundtouch-player -host 192.0.2.100
|
||||
./soundtouch-player --devices 192.0.2.100
|
||||
```
|
||||
|
||||
### Command Line Options
|
||||
@@ -137,7 +137,7 @@ device datastore).
|
||||
The application automatically discovers SoundTouch devices using:
|
||||
- **mDNS discovery** for local network devices
|
||||
- **UPnP/SSDP discovery** as fallback
|
||||
- **Manual device addition** via IP address
|
||||
- **Configured devices** via `--devices`, retried whenever discovery runs
|
||||
|
||||
### Real-time Updates
|
||||
The interface maintains WebSocket connections to each device for instant updates of:
|
||||
|
||||
@@ -161,7 +161,7 @@ func main() {
|
||||
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
|
||||
}
|
||||
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
|
||||
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
@@ -170,6 +170,11 @@ func main() {
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
|
||||
|
||||
// Register configured devices immediately rather than waiting for
|
||||
// the full mDNS/UPnP sweep below (bounded by cfg.DiscoveryTimeout,
|
||||
// currently 10s) to complete. manualHosts are also folded into
|
||||
// discoveryService's PreferredDevices so a host that's offline
|
||||
// right now still gets retried on every subsequent discovery pass.
|
||||
for _, host := range manualHosts {
|
||||
webApp.AddDeviceByHost(host, 8090, "manual")
|
||||
}
|
||||
|
||||
+151
-107
@@ -300,6 +300,18 @@ var serviceFlags = []cli.Flag{
|
||||
Value: "5m",
|
||||
EnvVars: []string{"DISCOVERY_INTERVAL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "device-seed-retry-interval",
|
||||
Usage: "Interval between embedded-player startup retries for unreachable persisted devices",
|
||||
Value: "30s",
|
||||
EnvVars: []string{"DEVICE_SEED_RETRY_INTERVAL"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "device-seed-retry-window",
|
||||
Usage: "Bounded window during which the embedded player retries unreachable persisted devices at startup",
|
||||
Value: "10m",
|
||||
EnvVars: []string{"DEVICE_SEED_RETRY_WINDOW"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "update-check-enabled",
|
||||
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
|
||||
@@ -656,7 +668,7 @@ func main() {
|
||||
}
|
||||
|
||||
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
|
||||
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds, config.deviceSeedRetryInterval, config.deviceSeedRetryWindow)
|
||||
|
||||
r := setupRouter(server, stockholmHandler, webApp)
|
||||
|
||||
@@ -715,55 +727,57 @@ func showVersionInfo(_ *cli.Context) error {
|
||||
}
|
||||
|
||||
type serviceConfig struct {
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string // effective (derived or overridden)
|
||||
httpsOverride string // explicit override; "" = derive from serverURL
|
||||
httpsPort string
|
||||
httpsDefaultURL string // hostname-based fallback
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
internalPaths []string
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
updateCheckEnabled bool
|
||||
updateCheckInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
ttsGoogleAPIKey string
|
||||
ttsGoogleEndpoint string
|
||||
ttsLanguage string
|
||||
ttsVoice string
|
||||
ttsAppKey string
|
||||
ttsVolume int
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
port string
|
||||
bindAddr string
|
||||
addr string
|
||||
dataDir string
|
||||
hostname string
|
||||
serverURL string
|
||||
httpsServerURL string // effective (derived or overridden)
|
||||
httpsOverride string // explicit override; "" = derive from serverURL
|
||||
httpsPort string
|
||||
httpsDefaultURL string // hostname-based fallback
|
||||
httpsAddr string
|
||||
redact bool
|
||||
logBody bool
|
||||
record bool
|
||||
dnsEnabled bool
|
||||
dnsUpstream string
|
||||
dnsBind string
|
||||
internalPaths []string
|
||||
tlsExtraHosts []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
deviceSeedRetryInterval time.Duration
|
||||
deviceSeedRetryWindow time.Duration
|
||||
updateCheckEnabled bool
|
||||
updateCheckInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
spotifyClientSecret string
|
||||
spotifyRedirectURI string
|
||||
spotifyTokenURL string
|
||||
spotifyAPIBase string
|
||||
amazonClientID string
|
||||
amazonClientSecret string
|
||||
amazonRedirectURI string
|
||||
amazonTokenURL string
|
||||
amazonProfileURL string
|
||||
tuneInOpmlURL string
|
||||
tuneInAPIURL string
|
||||
mgmtUsername string
|
||||
mgmtPassword string
|
||||
ttsProvider string
|
||||
ttsGoogleAPIKey string
|
||||
ttsGoogleEndpoint string
|
||||
ttsLanguage string
|
||||
ttsVoice string
|
||||
ttsAppKey string
|
||||
ttsVolume int
|
||||
migrationEnabled bool
|
||||
migrationDryRun bool
|
||||
stockholmDir string
|
||||
stockholmBasePath string
|
||||
}
|
||||
|
||||
// resolveFallbackHost picks the host used to guess a server URL when
|
||||
@@ -860,6 +874,24 @@ func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
discoveryInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
deviceSeedRetryIntervalStr := c.String("device-seed-retry-interval")
|
||||
|
||||
deviceSeedRetryInterval, err := time.ParseDuration(deviceSeedRetryIntervalStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse device seed retry interval %s, using default 30s: %v", sanitizeLog(deviceSeedRetryIntervalStr), err)
|
||||
|
||||
deviceSeedRetryInterval = 30 * time.Second
|
||||
}
|
||||
|
||||
deviceSeedRetryWindowStr := c.String("device-seed-retry-window")
|
||||
|
||||
deviceSeedRetryWindow, err := time.ParseDuration(deviceSeedRetryWindowStr)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to parse device seed retry window %s, using default 10m: %v", sanitizeLog(deviceSeedRetryWindowStr), err)
|
||||
|
||||
deviceSeedRetryWindow = 10 * time.Minute
|
||||
}
|
||||
|
||||
updateCheckEnabled := c.Bool("update-check-enabled")
|
||||
updateCheckIntervalStr := c.String("update-check-interval")
|
||||
|
||||
@@ -898,55 +930,57 @@ func loadConfig(c *cli.Context) (serviceConfig, error) {
|
||||
stockholmBasePath := c.String("stockholm-base-path")
|
||||
|
||||
return serviceConfig{
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
httpsPort: httpsPort,
|
||||
httpsDefaultURL: httpsDefaultURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
internalPaths: internalPaths,
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
updateCheckEnabled: updateCheckEnabled,
|
||||
updateCheckInterval: updateCheckInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
amazonClientID: amazonClientID,
|
||||
amazonClientSecret: amazonClientSecret,
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
ttsGoogleAPIKey: ttsGoogleAPIKey,
|
||||
ttsGoogleEndpoint: ttsGoogleEndpoint,
|
||||
ttsLanguage: ttsLanguage,
|
||||
ttsVoice: ttsVoice,
|
||||
ttsAppKey: ttsAppKey,
|
||||
ttsVolume: ttsVolume,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
port: port,
|
||||
bindAddr: bindAddr,
|
||||
addr: addr,
|
||||
dataDir: dataDir,
|
||||
hostname: fallbackHost,
|
||||
serverURL: serverURL,
|
||||
httpsServerURL: httpsServerURL,
|
||||
httpsOverride: httpsOverride,
|
||||
httpsPort: httpsPort,
|
||||
httpsDefaultURL: httpsDefaultURL,
|
||||
httpsAddr: httpsAddr,
|
||||
redact: redact,
|
||||
logBody: logBody,
|
||||
record: record,
|
||||
dnsEnabled: dnsEnabled,
|
||||
dnsUpstream: dnsUpstream,
|
||||
dnsBind: dnsBind,
|
||||
internalPaths: internalPaths,
|
||||
tlsExtraHosts: tlsExtraHosts,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
deviceSeedRetryInterval: deviceSeedRetryInterval,
|
||||
deviceSeedRetryWindow: deviceSeedRetryWindow,
|
||||
updateCheckEnabled: updateCheckEnabled,
|
||||
updateCheckInterval: updateCheckInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
spotifyClientSecret: spotifyClientSecret,
|
||||
spotifyRedirectURI: spotifyRedirectURI,
|
||||
spotifyTokenURL: spotifyTokenURL,
|
||||
spotifyAPIBase: spotifyAPIBase,
|
||||
amazonClientID: amazonClientID,
|
||||
amazonClientSecret: amazonClientSecret,
|
||||
amazonRedirectURI: amazonRedirectURI,
|
||||
amazonTokenURL: amazonTokenURL,
|
||||
amazonProfileURL: amazonProfileURL,
|
||||
tuneInOpmlURL: tuneInOpmlURL,
|
||||
tuneInAPIURL: tuneInAPIURL,
|
||||
mgmtUsername: mgmtUsername,
|
||||
mgmtPassword: mgmtPassword,
|
||||
ttsProvider: ttsProvider,
|
||||
ttsGoogleAPIKey: ttsGoogleAPIKey,
|
||||
ttsGoogleEndpoint: ttsGoogleEndpoint,
|
||||
ttsLanguage: ttsLanguage,
|
||||
ttsVoice: ttsVoice,
|
||||
ttsAppKey: ttsAppKey,
|
||||
ttsVolume: ttsVolume,
|
||||
migrationEnabled: migrationEnabled,
|
||||
migrationDryRun: migrationDryRun,
|
||||
stockholmDir: stockholmDir,
|
||||
stockholmBasePath: stockholmBasePath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1427,7 +1461,7 @@ func runUpdateCheckTick(checker *updatecheck.Checker, lastLoggedVersion string)
|
||||
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
|
||||
// devices-changed hook re-syncs the UI registry whenever the service's
|
||||
// discovery or a manual add changes the set.
|
||||
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
|
||||
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore, deviceSeedRetryInterval, deviceSeedRetryWindow time.Duration) *soundtouchweb.WebApp {
|
||||
webApp := soundtouchweb.NewWebApp()
|
||||
webApp.Version = version
|
||||
webApp.Commit = commit
|
||||
@@ -1444,11 +1478,10 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
// stream URLs the speaker fetches and the UI displays it.
|
||||
webApp.InternalServiceURL = internalURL
|
||||
|
||||
webApp.ExtraDeviceHosts = func() []string {
|
||||
webApp.ExtraDeviceHosts = func() ([]string, error) {
|
||||
devices, listErr := ds.ListAllDevices()
|
||||
if listErr != nil {
|
||||
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
|
||||
return nil
|
||||
return nil, fmt.Errorf("web UI: failed to list devices from datastore: %w", listErr)
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(devices))
|
||||
@@ -1458,7 +1491,7 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
}
|
||||
}
|
||||
|
||||
return hosts
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
// UI "discover" runs the service's sweep, not a second mDNS stack.
|
||||
@@ -1478,9 +1511,20 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
|
||||
})
|
||||
|
||||
go func() {
|
||||
// Project the current device set into the UI; the devices-changed hook
|
||||
// and the service's periodic discovery keep it current from here on.
|
||||
webApp.SeedExtraDevices()
|
||||
// Project the current device set into the UI. During gateway boot the
|
||||
// service can start before persisted speaker addresses are routable, so
|
||||
// retry only those known addresses for a bounded startup window. The
|
||||
// devices-changed hook and explicit discovery keep it current afterwards.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), deviceSeedRetryWindow)
|
||||
defer cancel()
|
||||
|
||||
webApp.SeedExtraDevicesUntilReady(ctx, deviceSeedRetryInterval)
|
||||
|
||||
// Unconditional: a WebSocket client connected during a window where
|
||||
// every attempt inserted or removed nothing (e.g. no persisted devices
|
||||
// at all, or every persisted host stayed unreachable for the whole
|
||||
// window) must still see the current, converged device list once this
|
||||
// goroutine's work is done.
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -69,6 +70,57 @@ func TestResolveFallbackHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeviceSeedRetryTuning(t *testing.T) {
|
||||
t.Run("defaults", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryInterval != 30*time.Second {
|
||||
t.Errorf("deviceSeedRetryInterval = %s, want 30s", config.deviceSeedRetryInterval)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryWindow != 10*time.Minute {
|
||||
t.Errorf("deviceSeedRetryWindow = %s, want 10m", config.deviceSeedRetryWindow)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flags override the defaults", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--device-seed-retry-interval=5s",
|
||||
"--device-seed-retry-window=1m"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryInterval != 5*time.Second {
|
||||
t.Errorf("deviceSeedRetryInterval = %s, want 5s", config.deviceSeedRetryInterval)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryWindow != time.Minute {
|
||||
t.Errorf("deviceSeedRetryWindow = %s, want 1m", config.deviceSeedRetryWindow)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unparseable values fall back to the defaults", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t,
|
||||
"--device-seed-retry-interval=not-a-duration",
|
||||
"--device-seed-retry-window=also-not-a-duration"))
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig() error = %v", err)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryInterval != 30*time.Second {
|
||||
t.Errorf("deviceSeedRetryInterval = %s, want fallback 30s", config.deviceSeedRetryInterval)
|
||||
}
|
||||
|
||||
if config.deviceSeedRetryWindow != 10*time.Minute {
|
||||
t.Errorf("deviceSeedRetryWindow = %s, want fallback 10m", config.deviceSeedRetryWindow)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadConfig_DeploymentMode(t *testing.T) {
|
||||
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
|
||||
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
|
||||
|
||||
@@ -39,11 +39,8 @@ func TestPrintRoutes(t *testing.T) {
|
||||
// Now we might have "soundtouch-service.setupRouter.func1"
|
||||
// or "command-line-arguments.setupRouter.func1"
|
||||
// or "main.setupRouter.func1"
|
||||
// Let's remove the first part if it's a known varying package name
|
||||
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
|
||||
handlerName = handlerName[idx:]
|
||||
}
|
||||
// In case it's not setupRouter but still has a package prefix
|
||||
// Remove the leading package/binary-name segment(s), whatever form
|
||||
// they take.
|
||||
for {
|
||||
dotIdx := strings.Index(handlerName, ".")
|
||||
if dotIdx == -1 {
|
||||
|
||||
+2
-1
@@ -46,6 +46,7 @@ GET /api/control/devices/{id}/power-status soundtouch
|
||||
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
|
||||
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
|
||||
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
|
||||
GET /api/control/devices/{id}/zone/candidates soundtouchweb.(*WebApp).HandleGetZoneCandidates-fm
|
||||
GET /api/control/providers/library/servers soundtouchweb.(*WebApp).HandleDiscoverLibraryServers-fm
|
||||
GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
|
||||
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
|
||||
@@ -169,7 +170,7 @@ GET /streaming/sourceproviders handlers.(
|
||||
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
|
||||
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
|
||||
GET /v1/blacklist/{deviceId} setupRouter
|
||||
GET /web/* setupRouter.(*Server).HandleWeb
|
||||
GET /web/* handlers.(*Server).HandleWeb
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
|
||||
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
|
||||
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.6-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.6-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -71,7 +71,7 @@ services:
|
||||
start_period: 3s
|
||||
|
||||
tunein-mock:
|
||||
image: golang:1.26.6-alpine
|
||||
image: golang:1.27.0-alpine
|
||||
container_name: tunein-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -566,7 +566,7 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
|
||||
|
||||
```dockerfile
|
||||
# test/docker/Dockerfile
|
||||
FROM golang:1.25-alpine
|
||||
FROM golang:1.27.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
@@ -101,13 +101,13 @@ rename and network/firmware info.
|
||||
|
||||
---
|
||||
|
||||
## 4. Render stereo pairs as a single device
|
||||
## 4. Render stereo pairs as a single device (shipped)
|
||||
|
||||
Today soundtouch-player shows the two halves of a stereo pair (formed via
|
||||
`/addGroup` — see issue #252) as independent entries in the device list. The
|
||||
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
|
||||
presentation closes the perception gap BirdyBA flagged at
|
||||
<https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305>.
|
||||
soundtouch-player projects a valid two-speaker stereo pair (formed via
|
||||
`/addGroup` - see [issue #252](https://github.com/gesellix/Bose-SoundTouch/issues/252))
|
||||
as one logical control target. This restores the single-entry presentation
|
||||
expected by users while preserving both physical speakers in the service
|
||||
registry.
|
||||
|
||||
**Device API:**
|
||||
- `GET /getGroup` on each speaker — returns the current `<group>` with
|
||||
@@ -118,28 +118,30 @@ presentation closes the perception gap BirdyBA flagged at
|
||||
side is sufficient to detect the pair
|
||||
|
||||
**Backend:**
|
||||
- During device-list assembly, call `GET /getGroup` for each discovered device
|
||||
in parallel (matches the propagation pattern already used by
|
||||
`soundtouch-cli group create` in `cmd/soundtouch-cli/cmd_group.go`)
|
||||
- Bucket devices by `<masterDeviceId>` — each bucket emits one entry in the
|
||||
list response. Standalone devices stay as their own bucket-of-one
|
||||
- Expose pair metadata on the list entry so the UI can render role chips
|
||||
(`L`/`R`) and resolve role → physical device for actions
|
||||
- Poll `GET /getGroup` together with the other device status and consume
|
||||
`groupUpdated` events. A generation check prevents an older poll from
|
||||
overwriting a newer event.
|
||||
- Collapse only an exact two-member `LEFT`/`RIGHT` group whose registered
|
||||
members agree on the group claim. Malformed, conflicting, or ambiguous data
|
||||
fails open and leaves the physical entries visible.
|
||||
- Use the master speaker's existing registry key for the logical target, so
|
||||
controls continue to route through the master without changing the raw
|
||||
physical-device registry.
|
||||
- Use the same projection for the REST device list and the global player
|
||||
WebSocket snapshot.
|
||||
|
||||
**Frontend:**
|
||||
- Device list collapses paired devices into one card titled with both names
|
||||
(e.g. `"Wohnzimmer L+R"`) and role chips
|
||||
- Clicking the card opens a device-detail page that exposes both per-role
|
||||
status and a "Dissolve pair" action (DELETE flow, already wired in
|
||||
`soundtouch-cli group remove` and in fakespeaker's `/removeGroup` GET)
|
||||
- Standalone speakers continue to render as today
|
||||
- Render one card using the shared member name or the group's name.
|
||||
- Show pair availability as `Stereo pair n/2` and mark the card degraded when
|
||||
a member is unavailable or the group reports a non-OK state.
|
||||
- Hide the single-device remove action on a projected pair. Standalone
|
||||
speakers continue to render as before.
|
||||
|
||||
**Note:** Pair lifecycle (create / rename / remove) already works
|
||||
end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
|
||||
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
|
||||
against the fake speaker's group routes
|
||||
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
|
||||
presentation in soundtouch-player's device list — no protocol work required.
|
||||
**Note:** Pair lifecycle (create / rename / remove) remains available through
|
||||
the existing client and CLI group operations. The player intentionally does
|
||||
not expose a "Dissolve pair" action yet: its current remove operation deletes
|
||||
one physical registry record rather than performing an atomic pair lifecycle
|
||||
operation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -115,25 +115,25 @@ type ProductionSoundTouchService struct {
|
||||
type Config struct {
|
||||
// Server settings
|
||||
ListenAddr string `env:"LISTEN_ADDR" default:":8080"`
|
||||
|
||||
|
||||
// SoundTouch settings
|
||||
DeviceHosts []string `env:"DEVICE_HOSTS" separator:","`
|
||||
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"30s"`
|
||||
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT" default:"15s"`
|
||||
MaxRetries int `env:"MAX_RETRIES" default:"3"`
|
||||
|
||||
|
||||
// Connection pool
|
||||
MaxConnections int `env:"MAX_CONNECTIONS" default:"10"`
|
||||
IdleTimeout time.Duration `env:"IDLE_TIMEOUT" default:"5m"`
|
||||
|
||||
|
||||
// Monitoring
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED" default:"true"`
|
||||
HealthCheckInterval time.Duration `env:"HEALTH_CHECK_INTERVAL" default:"30s"`
|
||||
|
||||
|
||||
// Logging
|
||||
LogLevel string `env:"LOG_LEVEL" default:"info"`
|
||||
LogFormat string `env:"LOG_FORMAT" default:"json"`
|
||||
|
||||
|
||||
// Security
|
||||
EnableTLS bool `env:"ENABLE_TLS" default:"false"`
|
||||
TLSCertFile string `env:"TLS_CERT_FILE"`
|
||||
@@ -145,7 +145,7 @@ func LoadConfig() (*Config, error) {
|
||||
if err := env.Parse(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
@@ -153,15 +153,15 @@ func (c *Config) Validate() error {
|
||||
if len(c.DeviceHosts) == 0 {
|
||||
return fmt.Errorf("at least one device host must be specified")
|
||||
}
|
||||
|
||||
|
||||
if c.RequestTimeout < time.Second {
|
||||
return fmt.Errorf("request timeout must be at least 1 second")
|
||||
}
|
||||
|
||||
|
||||
if c.EnableTLS && (c.TLSCertFile == "" || c.TLSKeyFile == "") {
|
||||
return fmt.Errorf("TLS cert and key files required when TLS is enabled")
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -191,7 +191,7 @@ pool:
|
||||
monitoring:
|
||||
metrics_enabled: true
|
||||
health_check_interval: "30s"
|
||||
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
@@ -203,12 +203,12 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &cfg, cfg.Validate()
|
||||
}
|
||||
```
|
||||
@@ -224,14 +224,14 @@ func LoadConfigFromFile(path string) (*Config, error) {
|
||||
type SecureNetworkConfig struct {
|
||||
// Allowed source IP ranges
|
||||
AllowedCIDRs []string
|
||||
|
||||
|
||||
// Rate limiting
|
||||
RateLimit int
|
||||
RateLimitWindow time.Duration
|
||||
|
||||
|
||||
// TLS configuration
|
||||
TLSConfig *tls.Config
|
||||
|
||||
|
||||
// Timeouts for security
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
@@ -240,7 +240,7 @@ type SecureNetworkConfig struct {
|
||||
|
||||
func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
|
||||
// Add middleware
|
||||
handler := applyMiddleware(mux,
|
||||
corsMiddleware(),
|
||||
@@ -249,7 +249,7 @@ func NewSecureServer(config SecureNetworkConfig) *http.Server {
|
||||
loggingMiddleware(),
|
||||
metricsMiddleware(),
|
||||
)
|
||||
|
||||
|
||||
return &http.Server{
|
||||
Handler: handler,
|
||||
TLSConfig: config.TLSConfig,
|
||||
@@ -275,12 +275,12 @@ func (r *DeviceControlRequest) Validate() error {
|
||||
if err := validate.Struct(r); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Additional business logic validation
|
||||
if r.Action == "volume" && r.Volume == nil {
|
||||
return fmt.Errorf("volume value required for volume action")
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
@@ -302,12 +302,12 @@ func loadSecretsFromK8s() (*SecretsConfig, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
tlsKey, err := os.ReadFile("/etc/secrets/tls.key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
return &SecretsConfig{
|
||||
TLSCert: string(tlsCert),
|
||||
TLSKey: string(tlsKey),
|
||||
@@ -335,21 +335,21 @@ type Logger struct {
|
||||
|
||||
func NewLogger(level, format, component string) (*Logger, error) {
|
||||
logger := logrus.New()
|
||||
|
||||
|
||||
// Set level
|
||||
logLevel, err := logrus.ParseLevel(level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.SetLevel(logLevel)
|
||||
|
||||
|
||||
// Set format
|
||||
if format == "json" {
|
||||
logger.SetFormatter(&logrus.JSONFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return &Logger{
|
||||
Logger: logger,
|
||||
component: component,
|
||||
@@ -376,15 +376,15 @@ type Metrics struct {
|
||||
RequestsTotal prometheus.CounterVec
|
||||
RequestDuration prometheus.HistogramVec
|
||||
RequestsInFlight prometheus.GaugeVec
|
||||
|
||||
|
||||
// Device metrics
|
||||
DevicesConnected prometheus.Gauge
|
||||
DeviceHealth prometheus.GaugeVec
|
||||
WebSocketConnections prometheus.Gauge
|
||||
|
||||
|
||||
// Error metrics
|
||||
ErrorsTotal prometheus.CounterVec
|
||||
|
||||
|
||||
// Business metrics
|
||||
VolumeChanges prometheus.CounterVec
|
||||
SourceChanges prometheus.CounterVec
|
||||
@@ -400,7 +400,7 @@ func NewMetrics() *Metrics {
|
||||
},
|
||||
[]string{"method", "endpoint", "status"},
|
||||
),
|
||||
|
||||
|
||||
RequestDuration: *prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "soundtouch_request_duration_seconds",
|
||||
@@ -409,14 +409,14 @@ func NewMetrics() *Metrics {
|
||||
},
|
||||
[]string{"method", "endpoint"},
|
||||
),
|
||||
|
||||
|
||||
DevicesConnected: prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "soundtouch_devices_connected",
|
||||
Help: "Number of connected devices",
|
||||
},
|
||||
),
|
||||
|
||||
|
||||
DeviceHealth: *prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "soundtouch_device_health",
|
||||
@@ -425,7 +425,7 @@ func NewMetrics() *Metrics {
|
||||
[]string{"device_id", "device_name"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
// Register metrics
|
||||
prometheus.MustRegister(
|
||||
m.RequestsTotal,
|
||||
@@ -433,7 +433,7 @@ func NewMetrics() *Metrics {
|
||||
m.DevicesConnected,
|
||||
m.DeviceHealth,
|
||||
)
|
||||
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ type HealthChecker struct {
|
||||
func (hc *HealthChecker) Start(ctx context.Context) {
|
||||
ticker := time.NewTicker(hc.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -470,7 +470,7 @@ func (hc *HealthChecker) Start(ctx context.Context) {
|
||||
|
||||
func (hc *HealthChecker) checkAllDevices() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
|
||||
for deviceID, device := range hc.manager.devices {
|
||||
wg.Add(1)
|
||||
go func(id string, dev *DeviceInfo) {
|
||||
@@ -478,18 +478,18 @@ func (hc *HealthChecker) checkAllDevices() {
|
||||
hc.checkDevice(id, dev)
|
||||
}(deviceID, device)
|
||||
}
|
||||
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (hc *HealthChecker) checkDevice(deviceID string, device *DeviceInfo) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), hc.timeout)
|
||||
defer cancel()
|
||||
|
||||
|
||||
start := time.Now()
|
||||
err := device.Client.Ping()
|
||||
duration := time.Since(start)
|
||||
|
||||
|
||||
if err != nil {
|
||||
device.Status = DeviceStatusUnhealthy
|
||||
hc.metrics.DeviceHealth.WithLabelValues(deviceID, device.Name).Set(0)
|
||||
@@ -507,14 +507,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
healthy := 0
|
||||
total := 0
|
||||
|
||||
|
||||
for _, device := range hc.manager.devices {
|
||||
total++
|
||||
if device.Status == DeviceStatusHealthy {
|
||||
healthy++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
status := map[string]interface{}{
|
||||
"status": "ok",
|
||||
"devices": map[string]interface{}{
|
||||
@@ -524,14 +524,14 @@ func (hc *HealthChecker) HealthHandler() http.HandlerFunc {
|
||||
},
|
||||
"timestamp": time.Now().UTC(),
|
||||
}
|
||||
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
|
||||
if healthy < total {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
status["status"] = "degraded"
|
||||
}
|
||||
|
||||
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
}
|
||||
@@ -560,16 +560,16 @@ func NewConnectionPool(maxIdle, maxActive int, idleTimeout time.Duration) *Conne
|
||||
maxActive: maxActive,
|
||||
idleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
|
||||
// Start cleanup goroutine
|
||||
go cp.cleanup()
|
||||
|
||||
|
||||
return cp
|
||||
}
|
||||
|
||||
func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
||||
key := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
|
||||
// Check if connection exists and is valid
|
||||
if val, ok := cp.clients.Load(key); ok {
|
||||
conn := val.(*pooledConnection)
|
||||
@@ -580,35 +580,35 @@ func (cp *ConnectionPool) Get(host string, port int) (*client.Client, error) {
|
||||
// Connection expired, remove it
|
||||
cp.clients.Delete(key)
|
||||
}
|
||||
|
||||
|
||||
// Check active connection limit
|
||||
if atomic.LoadInt64(&cp.activeCount) >= int64(cp.maxActive) {
|
||||
return nil, fmt.Errorf("connection pool exhausted")
|
||||
}
|
||||
|
||||
|
||||
// Create new connection
|
||||
config := client.ClientConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
newClient := client.NewClient(config)
|
||||
|
||||
|
||||
// Test connection
|
||||
if err := newClient.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to %s:%d: %w", host, port, err)
|
||||
}
|
||||
|
||||
|
||||
conn := &pooledConnection{
|
||||
client: newClient,
|
||||
lastUsed: time.Now(),
|
||||
created: time.Now(),
|
||||
}
|
||||
|
||||
|
||||
cp.clients.Store(key, conn)
|
||||
atomic.AddInt64(&cp.activeCount, 1)
|
||||
|
||||
|
||||
return newClient, nil
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ type pooledConnection struct {
|
||||
func (cp *ConnectionPool) cleanup() {
|
||||
ticker := time.NewTicker(cp.idleTimeout / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
cp.clients.Range(func(key, val interface{}) bool {
|
||||
@@ -649,10 +649,10 @@ func NewCacheManager() *CacheManager {
|
||||
return &CacheManager{
|
||||
// Device info rarely changes, cache for 1 hour
|
||||
deviceInfoCache: cache.New(1*time.Hour, 2*time.Hour),
|
||||
|
||||
|
||||
// Capabilities never change, cache for 24 hours
|
||||
capabilitiesCache: cache.New(24*time.Hour, 48*time.Hour),
|
||||
|
||||
|
||||
// Volume changes frequently, cache for 5 seconds
|
||||
volumeCache: cache.New(5*time.Second, 10*time.Second),
|
||||
}
|
||||
@@ -662,12 +662,12 @@ func (cm *CacheManager) GetDeviceInfo(deviceID string, fetcher func() (*models.D
|
||||
if cached, found := cm.deviceInfoCache.Get(deviceID); found {
|
||||
return cached.(*models.DeviceInfo), nil
|
||||
}
|
||||
|
||||
|
||||
info, err := fetcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
cm.deviceInfoCache.Set(deviceID, info, cache.DefaultExpiration)
|
||||
return info, nil
|
||||
}
|
||||
@@ -702,7 +702,7 @@ func NewResilientSoundTouchService(client *client.Client) *ResilientSoundTouchSe
|
||||
log.Printf("Circuit breaker '%s' changed from '%s' to '%s'", name, from, to)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
return &ResilientSoundTouchService{
|
||||
client: client,
|
||||
cb: gobreaker.NewCircuitBreaker(settings),
|
||||
@@ -713,12 +713,12 @@ func (r *ResilientSoundTouchService) SetVolume(deviceID string, volume int) erro
|
||||
result, err := r.cb.Execute(func() (interface{}, error) {
|
||||
return nil, r.client.SetVolume(volume)
|
||||
})
|
||||
|
||||
|
||||
if err != nil {
|
||||
r.metrics.ErrorsTotal.WithLabelValues("circuit_breaker", "volume").Inc()
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
return result.(error)
|
||||
}
|
||||
```
|
||||
@@ -730,16 +730,16 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
// Setup signal handling
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
|
||||
// Start services
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
|
||||
// HTTP server
|
||||
server := &http.Server{
|
||||
Addr: app.config.ListenAddr,
|
||||
Handler: app.handler,
|
||||
}
|
||||
|
||||
|
||||
g.Go(func() error {
|
||||
app.logger.Info("Starting HTTP server", "addr", app.config.ListenAddr)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
@@ -747,38 +747,38 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
// Health checker
|
||||
g.Go(func() error {
|
||||
return app.healthChecker.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
// WebSocket manager
|
||||
g.Go(func() error {
|
||||
return app.wsManager.Start(ctx)
|
||||
})
|
||||
|
||||
|
||||
// Wait for shutdown signal
|
||||
go func() {
|
||||
<-sigChan
|
||||
app.logger.Info("Shutdown signal received")
|
||||
|
||||
|
||||
// Graceful shutdown with timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
|
||||
// Shutdown HTTP server
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
app.logger.Error("HTTP server shutdown error", "error", err)
|
||||
}
|
||||
|
||||
|
||||
// Close WebSocket connections
|
||||
app.wsManager.Shutdown(shutdownCtx)
|
||||
|
||||
|
||||
// Close connection pool
|
||||
app.connectionPool.Close()
|
||||
}()
|
||||
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
@@ -791,7 +791,7 @@ func (app *Application) Run(ctx context.Context) error {
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.27.0-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
@@ -830,7 +830,7 @@ services:
|
||||
networks:
|
||||
- soundtouch-net
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
ports:
|
||||
@@ -839,7 +839,7 @@ services:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
networks:
|
||||
- soundtouch-net
|
||||
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
ports:
|
||||
@@ -1011,7 +1011,7 @@ groups:
|
||||
annotations:
|
||||
summary: "SoundTouch device {{ $labels.device_name }} is unhealthy"
|
||||
description: "Device {{ $labels.device_id }} has been unhealthy for more than 2 minutes"
|
||||
|
||||
|
||||
- alert: HighErrorRate
|
||||
expr: rate(soundtouch_errors_total[5m]) > 0.1
|
||||
for: 5m
|
||||
@@ -1020,7 +1020,7 @@ groups:
|
||||
annotations:
|
||||
summary: "High error rate detected"
|
||||
description: "Error rate is {{ $value }} errors/second over the last 5 minutes"
|
||||
|
||||
|
||||
- alert: ServiceDown
|
||||
expr: up{job="soundtouch"} == 0
|
||||
for: 1m
|
||||
@@ -1040,33 +1040,33 @@ func (m *Manager) BackupConfigurations() error {
|
||||
Timestamp: time.Now(),
|
||||
Devices: make(map[string]DeviceConfig),
|
||||
}
|
||||
|
||||
|
||||
for deviceID, device := range m.devices {
|
||||
config := DeviceConfig{}
|
||||
|
||||
|
||||
// Backup presets
|
||||
if presets, err := device.Client.GetPresets(); err == nil {
|
||||
config.Presets = presets
|
||||
}
|
||||
|
||||
|
||||
// Backup settings
|
||||
if volume, err := device.Client.GetVolume(); err == nil {
|
||||
config.Volume = volume.TargetVolume
|
||||
}
|
||||
|
||||
|
||||
if bass, err := device.Client.GetBass(); err == nil {
|
||||
config.Bass = bass.TargetBass
|
||||
}
|
||||
|
||||
|
||||
backup.Devices[deviceID] = config
|
||||
}
|
||||
|
||||
|
||||
// Save to file
|
||||
data, err := json.MarshalIndent(backup, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
filename := fmt.Sprintf("backup_%s.json", time.Now().Format("2006-01-02_15-04-05"))
|
||||
return os.WriteFile(filepath.Join(m.config.BackupDir, filename), data, 0644)
|
||||
}
|
||||
@@ -1083,7 +1083,7 @@ func init() {
|
||||
runtime.GOMAXPROCS(int(limit))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set GC target percentage
|
||||
if os.Getenv("GOGC") == "" {
|
||||
debug.SetGCPerc
|
||||
|
||||
@@ -156,33 +156,35 @@ The service supports multiple ways to configure its behavior. When multiple sour
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
| Variable | Flag | Description | Default |
|
||||
|------------------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
|
||||
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
|
||||
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
|
||||
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
|
||||
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
|
||||
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
|
||||
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
|
||||
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
|
||||
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
|
||||
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
|
||||
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
|
||||
| `DISCOVERY_ENABLED` | `--discovery-enabled` | Enable periodic device discovery | `true` |
|
||||
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
|
||||
| `DEVICE_SEED_RETRY_INTERVAL` | `--device-seed-retry-interval` | Interval between embedded-player startup retries for persisted devices that failed their first probe (e.g. LAN not yet routable on a cold boot) | `30s` |
|
||||
| `DEVICE_SEED_RETRY_WINDOW` | `--device-seed-retry-window` | Bounded window during which the embedded player retries those unreachable persisted devices at startup | `10m` |
|
||||
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
|
||||
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
|
||||
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
|
||||
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
|
||||
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
|
||||
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
|
||||
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
|
||||
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
|
||||
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
|
||||
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
|
||||
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
|
||||
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
|
||||
@@ -594,6 +594,28 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
|
||||
|
||||
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
|
||||
|
||||
### ❌ Presets get wiped after a reboot, on a speaker sharing its Marge account with other devices {#preset-wipe-shared-account}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
- Presets are programmed and confirmed correct (e.g. via the Admin UI or `soundtouch-cli`), but after a plain reboot of the speaker, its own preset list comes back empty (`<presets />`) — even though the service's own `Presets.xml` for that device is untouched and still shows the correct presets.
|
||||
- The affected speaker is one of several devices under the **same** Marge account — for example a separate on-device AfterTouch instance per speaker, or several physical speakers migrated to one shared account.
|
||||
- Clicking **Sync** in the Admin UI can also lose presets, but since v0.129.0 that path shows a confirmation warning before it overwrites anything destructively — that's a different, already-fixed issue (a stale-snapshot overwrite guard), not the reboot behavior described here.
|
||||
|
||||
**Cause:**
|
||||
|
||||
Not fully root-caused — this is firmware-internal. A byte-exact capture of the speaker's own `/full` request confirmed AfterTouch serves the correct preset data at the exact moment of the reboot-triggered resync; the wipe happens *after* that, entirely inside the speaker's own firmware callback chain, with no further network exchange to intercept from the service side. The trigger correlates with the **number of devices** listed under the account, not the account ID itself: removing the other devices from the account fixed it for one reporter, while changing only the account ID (with the other devices still present) did not. This isn't a universal shared-account problem either — a setup using a distinct account ID per speaker, with discovery left enabled, has not reproduced it — so treat this as an observed correlation, not a proven mechanism. See [issue #614](https://github.com/gesellix/Bose-SoundTouch/issues/614) for the full debugging history.
|
||||
|
||||
**Workaround (confirmed working, root cause still open):**
|
||||
|
||||
1. Admin UI → **Settings** → disable **"Enable Periodic Discovery"** first. Order matters — leaving it on lets a background sweep re-add a device you just removed, mid-cleanup.
|
||||
2. Admin UI → **Devices** tab → click **✕** to remove every other device from the account, leaving only the speaker you're troubleshooting.
|
||||
3. Reboot the speaker and confirm the presets survive.
|
||||
|
||||
This is fully reversible: re-enabling discovery brings the other devices back as harmless entries, and it doesn't touch their own presets/recents.
|
||||
|
||||
If you'd rather not change device-list membership, the Health tab's **"Restore presets to speaker"** QuickFix pushes the service's stored presets back onto the speaker without a reboot — a workaround for the symptom rather than the trigger, but useful if you hit this again before removing devices.
|
||||
|
||||
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
|
||||
|
||||
**Symptoms:**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.0
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.123.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.0
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.123.0
|
||||
require github.com/gesellix/bose-soundtouch v0.128.0
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.6
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
filippo.io/age v1.3.1
|
||||
github.com/chromedp/chromedp v0.16.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-chi/chi/v5 v5.3.2
|
||||
github.com/google/gopacket v1.1.19
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/mdns v1.0.7
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/russross/blackfriday/v2 v2.1.0
|
||||
github.com/sergi/go-diff v1.4.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
@@ -33,8 +33,6 @@ require (
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
golang.org/x/image v0.45.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -17,8 +17,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
|
||||
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
@@ -27,8 +27,6 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -40,8 +38,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -89,8 +87,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
Generated
+7
@@ -7,6 +7,7 @@
|
||||
"name": "@gesellix/bose-soundtouch",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-module-shims": "2.8.4",
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
@@ -14,6 +15,12 @@
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-shims": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.4.tgz",
|
||||
"integrity": "sha512-ea5srn5L89PWVad6Qle6r2kg+HvvLiL/GHqgNx06eFrkERHkrTFWaqo1w8Sd+XI3Xr0iAG5x3ZQ7mQ/hnQzNpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htm": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-module-shims": "2.8.4",
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.8"
|
||||
}
|
||||
|
||||
+12
-4
@@ -1428,10 +1428,18 @@ func (c *Client) GetZoneMembers() ([]string, error) {
|
||||
// An empty <group/> response is reported as a zero-value Group; callers can
|
||||
// distinguish with (*Group).IsEmpty().
|
||||
//
|
||||
// ST-10 is the only product that supports stereo pairs; on other devices
|
||||
// the call is harmless but will always return an empty group. The endpoint
|
||||
// is named /getGroup on the device (mirroring /getZone), even though some
|
||||
// third-party wikis document it as plain /group.
|
||||
// ST-10 is the only product that supports stereo pairs. Verified against
|
||||
// real hardware: a SoundTouch 20 does not reply to /getGroup promptly. The
|
||||
// device's own firmware ("AllegroWebserver") eventually answers with a
|
||||
// plain-text "AllegroWebserver timeout: /getGroup" error body after an
|
||||
// internal delay exceeding several seconds, but well within the client's
|
||||
// own timeout (30s by default, see DefaultConfig) the request just looks
|
||||
// like it never replied at all. Callers on a poll cycle must gate this call
|
||||
// behind a stereo-pair-capable model check (see stereoPairCapable in
|
||||
// pkg/service/soundtouchweb) instead of relying on a fast, harmless
|
||||
// response on unsupported models. The endpoint is named /getGroup on the
|
||||
// device (mirroring /getZone), even though some third-party wikis document
|
||||
// it as plain /group.
|
||||
func (c *Client) GetGroup() (*models.Group, error) {
|
||||
var g models.Group
|
||||
|
||||
|
||||
+41
-1
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "encoding/xml"
|
||||
import (
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Group represents a stereo pair of two ST10 SoundTouch speakers.
|
||||
type Group struct {
|
||||
@@ -32,3 +35,40 @@ type GroupRole struct {
|
||||
Role string `xml:"role"`
|
||||
IPAddress string `xml:"ipAddress,omitempty"`
|
||||
}
|
||||
|
||||
// SameGroup reports whether left and right describe the same stereo-pair
|
||||
// configuration, comparing role assignments by device ID rather than by
|
||||
// slice order. The device's own /getGroup response and its groupUpdated
|
||||
// WebSocket event both populate Roles.Roles directly from XML unmarshaling
|
||||
// in wire order, so a polled read and a pushed event for the identical pair
|
||||
// are not guaranteed to list roles in the same order -- comparing with
|
||||
// reflect.DeepEqual (order-sensitive) would then report a spurious change
|
||||
// even though nothing about the pair actually changed. Two nil Groups are
|
||||
// equal; exactly one nil is not.
|
||||
func SameGroup(left, right *Group) bool {
|
||||
if left == nil && right == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if left == nil || right == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if left.ID != right.ID || left.MasterDeviceID != right.MasterDeviceID ||
|
||||
len(left.Roles.Roles) != len(right.Roles.Roles) {
|
||||
return false
|
||||
}
|
||||
|
||||
rightRoles := make(map[string]string, len(right.Roles.Roles))
|
||||
for _, role := range right.Roles.Roles {
|
||||
rightRoles[strings.TrimSpace(role.DeviceID)] = strings.ToUpper(strings.TrimSpace(role.Role))
|
||||
}
|
||||
|
||||
for _, role := range left.Roles.Roles {
|
||||
if rightRoles[strings.TrimSpace(role.DeviceID)] != strings.ToUpper(strings.TrimSpace(role.Role)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
+30
-26
@@ -211,37 +211,41 @@ func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSecti
|
||||
}
|
||||
|
||||
itemType, _ := m["type"].(string)
|
||||
|
||||
if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
|
||||
name, _ := m["text"].(string)
|
||||
|
||||
section := models.BmxNavSection{
|
||||
Name: name,
|
||||
Items: make([]models.BmxNavItem, 0, len(children)),
|
||||
}
|
||||
|
||||
for _, child := range children {
|
||||
cm, ok := child.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
childType, _ := cm["type"].(string)
|
||||
if childType == "audio" {
|
||||
section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
|
||||
} else {
|
||||
section.Items = append(section.Items, tuneInNavigateLink(cm))
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, section)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
switch itemType {
|
||||
case "link":
|
||||
if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
|
||||
name, _ := m["text"].(string)
|
||||
|
||||
section := models.BmxNavSection{
|
||||
Name: name,
|
||||
Items: make([]models.BmxNavItem, 0, len(children)),
|
||||
}
|
||||
for _, child := range children {
|
||||
cm, ok := child.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
childType, _ := cm["type"].(string)
|
||||
if childType == "audio" {
|
||||
section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
|
||||
} else {
|
||||
section.Items = append(section.Items, tuneInNavigateLink(cm))
|
||||
}
|
||||
}
|
||||
|
||||
sections = append(sections, section)
|
||||
} else {
|
||||
topItems = append(topItems, tuneInNavigateLink(m))
|
||||
}
|
||||
topItems = append(topItems, tuneInNavigateLink(m))
|
||||
case "audio":
|
||||
topItems = append(topItems, tuneInNavigatePlayItem(m))
|
||||
case "text":
|
||||
// Ignore info text
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,78 @@ package bmx
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTuneInSectionsAshx_UntypedContainerSurfacesStations is a regression
|
||||
// test for a real-world bug: TuneIn's Browse.ashx?render=json responses
|
||||
// often wrap the actual stations for a category in a container object that
|
||||
// has "children" but no "type" field at all (unlike navigable sub-categories,
|
||||
// which are always type:"link"). The original parser's switch only ever
|
||||
// extracted "children" when itemType == "link", so these untyped containers
|
||||
// -- and every station nested inside them -- were silently dropped: browse
|
||||
// showed only category links, never any actual stations. Reproduces the
|
||||
// shape of a real captured Jazz-genre browse response.
|
||||
func TestTuneInSectionsAshx_UntypedContainerSurfacesStations(t *testing.T) {
|
||||
const wantStationName = "SmoothJazz.com.pl (Poland)"
|
||||
|
||||
payload := `{
|
||||
"head": {"status": "200", "title": "Jazz"},
|
||||
"body": [
|
||||
{
|
||||
"text": "Stations",
|
||||
"key": "stations",
|
||||
"children": [
|
||||
{
|
||||
"type": "audio",
|
||||
"text": "` + wantStationName + `",
|
||||
"URL": "http://opml.radiotime.com/Tune.ashx?id=s106565",
|
||||
"guide_id": "s106565",
|
||||
"subtext": "Smooth Jazz"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(payload))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
parsed, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse test server URL: %v", err)
|
||||
}
|
||||
|
||||
allowedTuneInHosts[parsed.Hostname()] = true
|
||||
defer delete(allowedTuneInHosts, parsed.Hostname())
|
||||
|
||||
sections, err := tuneInSectionsAshx(ts.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("tuneInSectionsAshx returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, section := range sections {
|
||||
for _, item := range section.Items {
|
||||
if item.Name == wantStationName {
|
||||
if item.Links == nil || item.Links.BmxPlayback == nil {
|
||||
t.Errorf("station %q was surfaced but has no BmxPlayback link: %+v", wantStationName, item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected station %q to be surfaced from the untyped container, got sections: %+v", wantStationName, sections)
|
||||
}
|
||||
|
||||
func TestTuneInRenderJSONURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -950,7 +950,10 @@ func (ds *DataStore) parseDeviceInfoFile(path string) (*models.ServiceDeviceInfo
|
||||
|
||||
// GetPresets retrieves all presets for the specified account and device.
|
||||
func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset, error) {
|
||||
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
|
||||
ds.fileMutex.RLock()
|
||||
presets, needsRewrite, err := ds.readPresetsNoLock(account, device)
|
||||
ds.fileMutex.RUnlock()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -966,19 +969,47 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
// readPresetsLocked is the locked read half of GetPresets. It returns the
|
||||
// parsed presets and a flag indicating whether the on-disk file used the
|
||||
// legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsLocked(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
// MutatePresets atomically reads the current preset list, transforms it via
|
||||
// mutate, and persists the result — holding a single write lock for the
|
||||
// entire read-mutate-write cycle. Calling GetPresets followed by a separate
|
||||
// SavePresets leaves a lost-update window open: two concurrent callers can
|
||||
// each read the same starting list, mutate different entries, and the
|
||||
// second writer's SavePresets silently clobbers the first writer's update.
|
||||
// That's the exact interleave that dropped a preset during #614's rapid-fire
|
||||
// repro (overlapping PUT .../preset/N requests). Callers that read-then-write
|
||||
// a single device's presets should use this instead of GetPresets+SavePresets.
|
||||
func (ds *DataStore) MutatePresets(account, device string, mutate func(current []models.ServicePreset) ([]models.ServicePreset, error)) ([]models.ServicePreset, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, _, err := ds.readPresetsNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.savePresetsNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readPresetsNoLock is the lock-free read half of GetPresets/MutatePresets.
|
||||
// Callers must already hold ds.fileMutex (for reading or writing). It
|
||||
// returns the parsed presets and a flag indicating whether the on-disk file
|
||||
// used the legacy <ContentItem> (capital C) format that needs rewriting.
|
||||
func (ds *DataStore) readPresetsNoLock(account, device string) ([]models.ServicePreset, bool, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("[Datastore] readPresetsLocked: no Presets.xml at %s — reporting no presets", sanitizeLog(path))
|
||||
log.Printf("[Datastore] readPresetsNoLock: no Presets.xml at %s — reporting no presets", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -991,7 +1022,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
// error, so the device-level /presets endpoint returns an empty list
|
||||
// instead of HTTP 500. See #458.
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
log.Printf("[Datastore] readPresetsNoLock: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -1023,7 +1054,7 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
|
||||
needsRewrite := !bytes.Equal(normalized, data)
|
||||
|
||||
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
|
||||
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
log.Printf("[Datastore] readPresetsNoLock: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
|
||||
|
||||
return []models.ServicePreset{}, false, nil
|
||||
}
|
||||
@@ -1077,7 +1108,7 @@ func repairLeakedSource(account, device, label, persistedSource, sourceID string
|
||||
return persistedSource
|
||||
}
|
||||
|
||||
sources, err := ds.getConfiguredSourcesLocked(account, device)
|
||||
sources, err := ds.getConfiguredSourcesNoLock(account, device)
|
||||
if err != nil {
|
||||
return persistedSource
|
||||
}
|
||||
@@ -1103,11 +1134,11 @@ func isLeakedSourceValue(s string) bool {
|
||||
return s == "" || s == "Audio"
|
||||
}
|
||||
|
||||
// getConfiguredSourcesLocked is GetConfiguredSources without the
|
||||
// getConfiguredSourcesNoLock is GetConfiguredSources without the
|
||||
// fileMutex.RLock() — callers must already hold it. Used by
|
||||
// repairLeakedSource from within GetPresets/GetRecents which already
|
||||
// hold the lock.
|
||||
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
|
||||
func (ds *DataStore) getConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
@@ -1155,6 +1186,12 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.savePresetsNoLock(account, device, presets)
|
||||
}
|
||||
|
||||
// savePresetsNoLock is the lock-free write half of
|
||||
// SavePresets/MutatePresets. Callers must already hold ds.fileMutex.Lock().
|
||||
func (ds *DataStore) savePresetsNoLock(account, device string, presets []models.ServicePreset) error {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.PresetsFile)
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
@@ -1318,6 +1355,38 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
return ds.readRecentsNoLock(account, device)
|
||||
}
|
||||
|
||||
// MutateRecents atomically reads the current recents list, transforms it
|
||||
// via mutate, and persists the result — holding a single write lock for the
|
||||
// entire read-mutate-write cycle. See MutatePresets for why this matters: a
|
||||
// separate GetRecents followed by SaveRecents leaves a lost-update window
|
||||
// open between concurrent callers.
|
||||
func (ds *DataStore) MutateRecents(account, device string, mutate func(current []models.ServiceRecent) ([]models.ServiceRecent, error)) ([]models.ServiceRecent, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, err := ds.readRecentsNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.saveRecentsNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readRecentsNoLock is the lock-free read half of GetRecents/MutateRecents.
|
||||
// Callers must already hold ds.fileMutex (for reading or writing).
|
||||
func (ds *DataStore) readRecentsNoLock(account, device string) ([]models.ServiceRecent, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.RecentsFile)
|
||||
|
||||
data, err := ds.rootReadFile(path)
|
||||
@@ -1430,6 +1499,12 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.saveRecentsNoLock(account, device, recents)
|
||||
}
|
||||
|
||||
// saveRecentsNoLock is the lock-free write half of
|
||||
// SaveRecents/MutateRecents. Callers must already hold ds.fileMutex.Lock().
|
||||
func (ds *DataStore) saveRecentsNoLock(account, device string, recents []models.ServiceRecent) error {
|
||||
dir := ds.AccountDeviceDir(account, device)
|
||||
if err := ds.rootMkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
@@ -1935,6 +2010,40 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
|
||||
ds.fileMutex.RLock()
|
||||
defer ds.fileMutex.RUnlock()
|
||||
|
||||
return ds.readConfiguredSourcesNoLock(account, device)
|
||||
}
|
||||
|
||||
// MutateConfiguredSources atomically reads the current configured-source
|
||||
// list, transforms it via mutate, and persists the result — holding a
|
||||
// single write lock for the entire read-mutate-write cycle. See
|
||||
// MutatePresets for why this matters: a separate GetConfiguredSources
|
||||
// followed by SaveConfiguredSources leaves a lost-update window open
|
||||
// between concurrent callers.
|
||||
func (ds *DataStore) MutateConfiguredSources(account, device string, mutate func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error)) ([]models.ConfiguredSource, error) {
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
current, err := ds.readConfiguredSourcesNoLock(account, device)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := mutate(current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := ds.saveConfiguredSourcesNoLock(account, device, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// readConfiguredSourcesNoLock is the lock-free read half of
|
||||
// GetConfiguredSources/MutateConfiguredSources. Callers must already hold
|
||||
// ds.fileMutex (for reading or writing).
|
||||
func (ds *DataStore) readConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
|
||||
// defaultSources is the fallback used whenever there is no usable
|
||||
@@ -2106,6 +2215,13 @@ func (ds *DataStore) SaveConfiguredSources(account, device string, sources []mod
|
||||
ds.fileMutex.Lock()
|
||||
defer ds.fileMutex.Unlock()
|
||||
|
||||
return ds.saveConfiguredSourcesNoLock(account, device, sources)
|
||||
}
|
||||
|
||||
// saveConfiguredSourcesNoLock is the lock-free write half of
|
||||
// SaveConfiguredSources/MutateConfiguredSources. Callers must already hold
|
||||
// ds.fileMutex.Lock().
|
||||
func (ds *DataStore) saveConfiguredSourcesNoLock(account, device string, sources []models.ConfiguredSource) error {
|
||||
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
|
||||
if err := ds.rootMkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
|
||||
@@ -203,6 +203,9 @@ func TestHandleTuneInToken(t *testing.T) {
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// Even when the speaker presents a refresh_token from a prior session,
|
||||
// the handler always mints its own token rather than echoing the input
|
||||
// back verbatim.
|
||||
payload := `{"grant_type":"refresh_token","refresh_token":"test-refresh-token"}`
|
||||
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
@@ -214,16 +217,98 @@ func TestHandleTuneInToken(t *testing.T) {
|
||||
t.Errorf("Expected status 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp["access_token"] != "test-refresh-token" {
|
||||
t.Errorf("Expected access_token 'test-refresh-token', got %v", resp["access_token"])
|
||||
accessToken, _ := resp["access_token"].(string)
|
||||
refreshToken, _ := resp["refresh_token"].(string)
|
||||
|
||||
if accessToken == "" {
|
||||
t.Error("Expected a non-empty access_token")
|
||||
}
|
||||
if resp["refresh_token"] != "test-refresh-token" {
|
||||
t.Errorf("Expected refresh_token 'test-refresh-token', got %v", resp["refresh_token"])
|
||||
if refreshToken == "" {
|
||||
t.Error("Expected a non-empty refresh_token")
|
||||
}
|
||||
if accessToken != refreshToken {
|
||||
t.Errorf("Expected access_token and refresh_token to match, got %q and %q", accessToken, refreshToken)
|
||||
}
|
||||
if accessToken == "test-refresh-token" {
|
||||
t.Error("Expected a minted token, not an echo of the request's refresh_token")
|
||||
}
|
||||
|
||||
embedded, ok := resp["_embedded"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected _embedded object in response, got %v", resp["_embedded"])
|
||||
}
|
||||
if _, ok := embedded["bmx_account"]; !ok {
|
||||
t.Error("Expected _embedded.bmx_account in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTuneInToken_Bootstrap covers the real-world trigger of the
|
||||
// original bug: a speaker's very first TUNEIN token request, made under
|
||||
// authenticationModel.anonymousAccount (autoCreate: true), has no prior
|
||||
// refresh_token to present at all. The old handler echoed back whatever
|
||||
// (possibly empty/absent) refresh_token it received, so this exact request
|
||||
// used to round-trip an empty token and the speaker would reject every
|
||||
// subsequent TUNEIN ContentItem selection with INVALID_SOURCE — even though
|
||||
// browse and search worked fine and the same stream URL played successfully
|
||||
// via Play URL/LOCAL_INTERNET_RADIO.
|
||||
func TestHandleTuneInToken_Bootstrap(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
payload := `{"grant_type":"refresh_token"}`
|
||||
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %v", res.Status)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accessToken, _ := resp["access_token"].(string)
|
||||
refreshToken, _ := resp["refresh_token"].(string)
|
||||
|
||||
if accessToken == "" {
|
||||
t.Error("Bootstrap request (no refresh_token) must still receive a non-empty access_token")
|
||||
}
|
||||
if refreshToken == "" {
|
||||
t.Error("Bootstrap request (no refresh_token) must still receive a non-empty refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTuneInToken_MalformedBodyRejected covers the request-validation
|
||||
// path that stayed in place alongside the unconditional-mint fix: a body
|
||||
// that isn't even valid JSON is not a normal bootstrap call (which is still
|
||||
// well-formed JSON, just with an empty/absent refresh_token — see
|
||||
// TestHandleTuneInToken_Bootstrap), so it should be rejected rather than
|
||||
// silently minting a token anyway.
|
||||
func TestHandleTuneInToken_MalformedBodyRejected(t *testing.T) {
|
||||
r, _ := setupRouter("http://localhost:8001", nil)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader("not json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400 for a malformed body, got %v", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -118,8 +119,32 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInToken returns a TuneIn access token.
|
||||
// HandleTuneInToken returns an anonymous TuneIn access token.
|
||||
//
|
||||
// The registry advertises TUNEIN with authenticationModel.anonymousAccount
|
||||
// (autoCreate: true) — see bmx_services.json — so the speaker's very first
|
||||
// call here is a bootstrap request with no prior refresh_token to present.
|
||||
// This handler used to echo back whatever refresh_token the speaker sent
|
||||
// (mirroring an authenticated-refresh recording), which meant that very
|
||||
// first bootstrap call round-tripped an empty token. The speaker never
|
||||
// obtained a usable TuneIn account and subsequently rejected every TUNEIN
|
||||
// ContentItem selection with INVALID_SOURCE, even though /sources reported
|
||||
// TUNEIN as READY (READY only reflects registry presence, not a live
|
||||
// account). Match HandleOrionToken's unconditional-generation shape
|
||||
// instead: always mint a token, regardless of what the speaker sent.
|
||||
//
|
||||
// The token itself is a stable, constant value (datastore.GenerateSerialSecret
|
||||
// is a pure function of the hardcoded "tunein" literal), not a fresh or
|
||||
// per-device secret — it's the same value for every device and every call.
|
||||
// That's fine today only because the Authorization gate is disabled for all
|
||||
// TuneIn handlers (see HandleTuneInReport below) and nothing validates the
|
||||
// token's uniqueness; if either of those ever changes, this would need a
|
||||
// real per-device/per-session token instead.
|
||||
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
// The unconditional mint above means we never use the decoded values,
|
||||
// but we still decode the body so a genuinely malformed request (not a
|
||||
// normal bootstrap call, which is valid JSON with an empty/absent
|
||||
// refresh_token) gets a 400 instead of silently succeeding.
|
||||
var req struct {
|
||||
GrantType string `json:"grant_type"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -130,18 +155,23 @@ func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// For now, we return the provided refresh_token as access_token and refresh_token,
|
||||
// mirroring the behavior seen in the recordings.
|
||||
resp := map[string]string{
|
||||
"access_token": req.RefreshToken,
|
||||
"refresh_token": req.RefreshToken,
|
||||
token := datastore.GenerateSerialSecret("tunein")
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"_embedded": map[string]interface{}{
|
||||
"bmx_account": map[string]string{
|
||||
"displayName": "",
|
||||
"username": "",
|
||||
},
|
||||
},
|
||||
"access_token": token,
|
||||
"refresh_token": token,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1274,7 +1274,16 @@ func (s *Server) HandleTestDNSRedirection(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
// HandleInitialSync fetches presets, recents and sources from the device
|
||||
// and saves them to the datastore.
|
||||
//
|
||||
// If applying the fetched presets/recents would shrink what's already
|
||||
// stored, the sync is not applied — the response comes back 409 with the
|
||||
// diff describing what would be removed — unless the caller passes
|
||||
// ?confirmed=true, in which case it's applied unconditionally. Every call
|
||||
// re-fetches live from the speaker at that moment (see
|
||||
// setup.SyncDeviceData), so a confirmed retry re-checks current reality
|
||||
// rather than replaying a possibly-stale earlier response.
|
||||
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "deviceId")
|
||||
if deviceID == "" {
|
||||
@@ -1288,13 +1297,25 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
|
||||
confirmed := r.URL.Query().Get("confirmed") == "true"
|
||||
|
||||
result, err := s.sm.SyncDeviceData(deviceIP, confirmed)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if !result.Applied {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
if encodeErr := json.NewEncoder(w).Encode(result); encodeErr != nil {
|
||||
log.Printf("HandleInitialSync: failed to encode result for device %s: %s", sanitizeLog(deviceID), sanitizeErr(encodeErr))
|
||||
}
|
||||
}
|
||||
|
||||
// HandleRebootDevice reboots a device.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed is
|
||||
// an HTTP-level regression test for #614's Sync-button data-loss bug (see
|
||||
// setup.TestSyncDeviceData_DestructiveSyncRequiresConfirmation for the
|
||||
// lower-level coverage of the same fix): a device already has more presets
|
||||
// stored than the mock speaker's live /presets now reports. The first,
|
||||
// unconfirmed sync request must come back 409 with the diff and must not
|
||||
// write anything; a retry with ?confirmed=true must apply it.
|
||||
func TestHandleInitialSync_DestructiveSyncReturns409ThenAppliesWhenConfirmed(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
// A real local server, not a black-hole IP: notifySpeakerSourcesUpdated
|
||||
// (part of the confirmed-apply path) uses its own HTTP client rather
|
||||
// than the injectable sm.HTTPGet, so it needs somewhere real to fail
|
||||
// fast against (404) instead of timing out.
|
||||
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?><info deviceID="%s"><name>Test Device</name><type>SoundTouch 20</type><margeAccountUUID>%s</margeAccountUUID></info>`, deviceID, accountID)
|
||||
case "/presets":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><presets><preset id="1"><ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="/x" isPresetable="true"><itemName>Station 1</itemName></ContentItem></preset></presets>`)
|
||||
case "/recents":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><recents></recents>`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockDevice.Close()
|
||||
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "handlers-sync-destructive-guard-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
seeded := []models.ServicePreset{
|
||||
{ID: "1", ButtonNumber: "1", ServiceContentItem: models.ServiceContentItem{Name: "Station 1"}},
|
||||
{ID: "2", ButtonNumber: "2", ServiceContentItem: models.ServiceContentItem{Name: "Station 2"}},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
|
||||
t.Fatalf("seed SavePresets: %v", err)
|
||||
}
|
||||
|
||||
if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
AccountID: accountID,
|
||||
IPAddress: deviceIP,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
sm := setup.NewManager("http://localhost:8000", ds, nil)
|
||||
|
||||
server := NewServer(ds, sm, "http://localhost:8000", false, false, false)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/api/setup/sync/{deviceId}", server.HandleInitialSync)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
// First, unconfirmed request: must be refused with 409.
|
||||
resp, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID, "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST sync (unconfirmed): %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 409 for a destructive unconfirmed sync, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result setup.SyncResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("decode 409 body: %v", err)
|
||||
}
|
||||
|
||||
if result.Applied {
|
||||
t.Fatal("expected Applied=false in the 409 response")
|
||||
}
|
||||
|
||||
if !result.Destructive {
|
||||
t.Fatal("expected Destructive=true in the 409 response")
|
||||
}
|
||||
|
||||
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after refused sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterRefusal) != 2 {
|
||||
t.Fatalf("expected the original 2 presets to survive the refused sync, got %d", len(presetsAfterRefusal))
|
||||
}
|
||||
|
||||
// Retry, confirmed: must apply.
|
||||
resp2, err := http.Post(ts.URL+"/api/setup/sync/"+deviceID+"?confirmed=true", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("POST sync (confirmed): %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp2.Body)
|
||||
t.Fatalf("expected 200 for a confirmed sync, got %d: %s", resp2.StatusCode, body)
|
||||
}
|
||||
|
||||
var confirmedResult setup.SyncResult
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&confirmedResult); err != nil {
|
||||
t.Fatalf("decode 200 body: %v", err)
|
||||
}
|
||||
|
||||
if !confirmedResult.Applied {
|
||||
t.Fatal("expected Applied=true after confirming")
|
||||
}
|
||||
|
||||
presetsAfterConfirm, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after confirmed sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterConfirm) != 1 {
|
||||
t.Fatalf("expected confirmed sync to shrink to 1 preset, got %d", len(presetsAfterConfirm))
|
||||
}
|
||||
}
|
||||
@@ -876,6 +876,57 @@ function getDeviceDisplayName(deviceId) {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
// buildSyncConfirmMessage renders a human-readable summary of a destructive
|
||||
// SyncResult (see setup.SyncResult/SyncResourceDiff) for window.confirm() —
|
||||
// e.g. "Sync would remove 1 preset: Ici Roussillon. Continue?".
|
||||
function buildSyncConfirmMessage(result) {
|
||||
const lines = ["This Data Sync would remove data that's currently stored:"];
|
||||
for (const diff of result.diffs || []) {
|
||||
if (!diff.destructive) {
|
||||
continue;
|
||||
}
|
||||
const removedNote = diff.removed && diff.removed.length ? ": " + diff.removed.join(", ") : "";
|
||||
lines.push("- " + diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount + removedNote);
|
||||
}
|
||||
lines.push("This usually means the speaker's own live data was incomplete at this moment. Continue anyway?");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// renderSyncResultList builds a <ul> summarising a successful SyncResult —
|
||||
// one <li> per resource, e.g. "presets: 6 → 6", plus a sources count. Built
|
||||
// via DOM APIs (not innerHTML string concatenation) since preset/recent
|
||||
// names ultimately come from user-editable station names on the speaker.
|
||||
function renderSyncResultList(result) {
|
||||
const ul = document.createElement("ul");
|
||||
|
||||
for (const diff of result.diffs || []) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = diff.resource + ": " + diff.currentCount + " → " + diff.incomingCount;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
|
||||
const sourcesLi = document.createElement("li");
|
||||
sourcesLi.textContent = "sources: " + (result.sourcesCount >= 0 ? result.sourcesCount : "sync failed");
|
||||
ul.appendChild(sourcesLi);
|
||||
|
||||
return ul;
|
||||
}
|
||||
|
||||
async function requestSync(deviceId, confirmed) {
|
||||
let url = "/api/setup/sync/" + encodeURIComponent(deviceId);
|
||||
if (confirmed) {
|
||||
url += "?confirmed=true";
|
||||
}
|
||||
const response = await fetch(url, {method: "POST"});
|
||||
let result = null;
|
||||
try {
|
||||
result = await response.clone().json();
|
||||
} catch (e) {
|
||||
// Non-JSON error body (e.g. a plain-text 500) — handled below via response.text().
|
||||
}
|
||||
return {response, result};
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
const deviceId = document.getElementById("sync-device-list").value;
|
||||
if (!deviceId) {
|
||||
@@ -895,14 +946,30 @@ async function startSync() {
|
||||
log.innerHTML = "";
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
|
||||
if (response.ok) {
|
||||
let {response, result} = await requestSync(deviceId, false);
|
||||
|
||||
if (response.status === 409 && result) {
|
||||
if (!confirm(buildSyncConfirmMessage(result))) {
|
||||
status.style.backgroundColor = "#eef";
|
||||
status.textContent = "Sync cancelled for " + display + " — nothing was changed.";
|
||||
return;
|
||||
}
|
||||
|
||||
({response, result} = await requestSync(deviceId, true));
|
||||
}
|
||||
|
||||
if (response.ok && result) {
|
||||
status.style.backgroundColor = "#dfd";
|
||||
status.textContent = "✅ Sync completed successfully for " + display + "!";
|
||||
results.style.display = "block";
|
||||
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
|
||||
|
||||
log.innerHTML = "";
|
||||
const intro = document.createElement("p");
|
||||
intro.textContent = "Data fetched and saved to local datastore for " + display + ".";
|
||||
log.appendChild(intro);
|
||||
log.appendChild(renderSyncResultList(result));
|
||||
} else {
|
||||
const err = await response.text();
|
||||
const err = result ? JSON.stringify(result) : await response.text();
|
||||
throw new Error(err);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1594,7 +1661,6 @@ async function fetchInteractions() {
|
||||
const path = i.path || i.Path || "";
|
||||
const status = i.status || i.Status || "";
|
||||
const category = i.category || i.Category || "";
|
||||
const session = i.session || i.Session || "";
|
||||
const file = i.file || i.File || "";
|
||||
const scmudcData = i.scmudc_data || i.SCMUDCData || null;
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package marge
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestConcurrentUpdatePresetNoLostUpdates is a regression test for #614's
|
||||
// 2026-08-23 reproduction: a reporter's script stored six presets via rapid,
|
||||
// overlapping PUT .../preset/N requests (visible in the speaker's own log as
|
||||
// interleaved connection IDs, never waiting for one PUT to complete before
|
||||
// firing the next). One preset silently vanished from Presets.xml.
|
||||
//
|
||||
// UpdatePreset used to do GetPresets, mutate one slot, SavePresets as three
|
||||
// separate steps with no lock spanning them — a classic lost-update race:
|
||||
// two concurrent calls can each read the same starting list, mutate
|
||||
// different slots, and the second writer's SavePresets clobbers the first
|
||||
// writer's update. Fixed by routing the write through
|
||||
// datastore.MutatePresets, which holds a single write lock for the whole
|
||||
// read-mutate-write cycle.
|
||||
func TestConcurrentUpdatePresetNoLostUpdates(t *testing.T) {
|
||||
tempDir, err := os.MkdirTemp("", "marge-concurrent-update-preset-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
account := "1234567"
|
||||
device := "B0D5CC25479C"
|
||||
|
||||
const presetCount = 6
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
errs := make([]error, presetCount)
|
||||
|
||||
for i := 1; i <= presetCount; i++ {
|
||||
wg.Add(1)
|
||||
|
||||
go func(presetNumber int) {
|
||||
defer wg.Done()
|
||||
|
||||
// sourceid 10003 is the canonical LOCAL_INTERNET_RADIO built-in
|
||||
// (see CanonicalSourceByID) — same shape as Henri's own repro
|
||||
// script, which stored six LOCAL_INTERNET_RADIO presets.
|
||||
putXML := []byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<preset>
|
||||
<name>Station %d</name>
|
||||
<sourceid>10003</sourceid>
|
||||
<location>/custom/v1/playback/station%d</location>
|
||||
<contentItemType>stationurl</contentItemType>
|
||||
</preset>`, presetNumber, presetNumber))
|
||||
|
||||
_, err := UpdatePreset(ds, account, device, presetNumber, putXML)
|
||||
errs[presetNumber-1] = err
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePreset(preset=%d) returned error: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets: %v", err)
|
||||
}
|
||||
|
||||
if len(presets) != presetCount {
|
||||
t.Fatalf("expected %d presets after %d concurrent UpdatePreset calls, got %d: %+v", presetCount, presetCount, len(presets), presets)
|
||||
}
|
||||
|
||||
for i, p := range presets {
|
||||
want := fmt.Sprintf("Station %d", i+1)
|
||||
if p.Name != want {
|
||||
t.Errorf("preset slot %d: expected name %q, got %q — a concurrent update was lost", i+1, want, p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-88
@@ -1508,19 +1508,18 @@ func AccountFullToXML(ds *datastore.DataStore, account string) ([]byte, error) {
|
||||
|
||||
// RemovePreset clears a preset for the specified account and device.
|
||||
func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber int) error {
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
|
||||
if presetNumber < 1 || presetNumber > len(presets) {
|
||||
// Preset doesn't exist or index out of range, nothing to do
|
||||
return presets, nil
|
||||
}
|
||||
|
||||
if presetNumber < 1 || presetNumber > len(presets) {
|
||||
// Preset doesn't exist or index out of range, nothing to do
|
||||
return nil
|
||||
}
|
||||
presets[presetNumber-1] = models.ServicePreset{}
|
||||
|
||||
presets[presetNumber-1] = models.ServicePreset{}
|
||||
return presets, nil
|
||||
})
|
||||
|
||||
return ds.SavePresets(account, device, presets)
|
||||
return err
|
||||
}
|
||||
|
||||
// resolvePresetSource resolves the source a preset PUT is referencing,
|
||||
@@ -1529,42 +1528,74 @@ func RemovePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
// returns the matched source plus the possibly-extended sources slice
|
||||
// (since auto-add appends). Returns (nil, sources) when no match could be
|
||||
// resolved — UpdatePreset turns that into a 500 with a diagnostic log line.
|
||||
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
|
||||
// findConfiguredSource looks up sourceID in sources, first by exact ID and
|
||||
// then — since the speaker sometimes sends the symbolic provider name (e.g.
|
||||
// <sourceid>TUNEIN</sourceid>) instead of a numeric ID — by SourceKeyType
|
||||
// for the handful of providers known to do that.
|
||||
func findConfiguredSource(sources []models.ConfiguredSource, sourceID string) *models.ConfiguredSource {
|
||||
for i := range sources {
|
||||
if sources[i].ID == sourceID {
|
||||
return &sources[i], sources
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: SourceID is the symbolic provider name (the speaker
|
||||
// sometimes sends e.g. <sourceid>TUNEIN</sourceid> instead of a
|
||||
// numeric ID); match by SourceKeyType.
|
||||
if sourceID == constants.ProviderInternetRadio || sourceID == constants.ProviderTunein || sourceID == constants.ProviderSpotify || sourceID == constants.ProviderAmazon {
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == sourceID {
|
||||
return &sources[i], sources
|
||||
return &sources[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
|
||||
if src := findConfiguredSource(sources, sourceID); src != nil {
|
||||
return src, sources
|
||||
}
|
||||
|
||||
// Auto-add a canonical built-in source the speaker referenced but
|
||||
// AfterTouch hasn't been told about (post-factory-reset state). For
|
||||
// account-bound sources (Spotify, Amazon) we can't synthesise
|
||||
// credentials, so the caller will reject the PUT instead.
|
||||
if canonical, ok := ds.CanonicalSourceByID(sourceID); ok {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
|
||||
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
|
||||
canonical, ok := ds.CanonicalSourceByID(sourceID)
|
||||
if !ok {
|
||||
return nil, sources
|
||||
}
|
||||
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): auto-adding canonical source id=%s type=%s providerid=%s — speaker referenced a built-in source not yet in AfterTouch's configured-sources list; saving so the preset can land",
|
||||
presetNumber, sanitizeLog(canonical.ID), sanitizeLog(canonical.SourceKeyType), sanitizeLog(canonical.SourceProviderID))
|
||||
|
||||
// Read-mutate-write atomically against the persisted list, not the
|
||||
// possibly-stale `sources` snapshot the caller already read — a
|
||||
// concurrent PUT for a different preset could be auto-adding (or have
|
||||
// just added) a source at the same time, and a plain Get+Save here
|
||||
// would silently lose whichever write landed second.
|
||||
updated, saveErr := ds.MutateConfiguredSources(account, device, func(current []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
if src := findConfiguredSource(current, sourceID); src != nil {
|
||||
// Another concurrent caller already added it; nothing to do.
|
||||
return current, nil
|
||||
}
|
||||
|
||||
return append(current, canonical), nil
|
||||
})
|
||||
if saveErr != nil {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
|
||||
presetNumber, sanitizeErr(saveErr))
|
||||
|
||||
sources = append(sources, canonical)
|
||||
if saveErr := ds.SaveConfiguredSources(account, device, sources); saveErr != nil {
|
||||
log.Printf("[Marge] UpdatePreset(preset=%d): SaveConfiguredSources after auto-add failed: %s — the preset will land but the source may not survive a service restart",
|
||||
presetNumber, sanitizeErr(saveErr))
|
||||
}
|
||||
|
||||
return &sources[len(sources)-1], sources
|
||||
}
|
||||
|
||||
return nil, sources
|
||||
if src := findConfiguredSource(updated, sourceID); src != nil {
|
||||
return src, updated
|
||||
}
|
||||
|
||||
updated = append(updated, canonical)
|
||||
|
||||
return &updated[len(updated)-1], updated
|
||||
}
|
||||
|
||||
// UpdatePreset updates or creates a preset for the specified account and device.
|
||||
@@ -1574,11 +1605,6 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
return nil, err
|
||||
}
|
||||
|
||||
presets, err := ds.GetPresets(account, device)
|
||||
if err != nil {
|
||||
presets = []models.ServicePreset{}
|
||||
}
|
||||
|
||||
var newPresetElem struct {
|
||||
Name string `xml:"name"`
|
||||
Username string `xml:"username"`
|
||||
@@ -1644,14 +1670,19 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
|
||||
Username: newPresetElem.Name,
|
||||
}
|
||||
|
||||
// Ensure presets list is large enough
|
||||
for len(presets) < presetNumber {
|
||||
presets = append(presets, models.ServicePreset{})
|
||||
}
|
||||
// Read-mutate-write atomically: a concurrent PUT for a different preset
|
||||
// number racing this one must not be able to clobber it. See
|
||||
// MutatePresets — this is the exact interleave that dropped a preset
|
||||
// during #614's rapid-fire repro.
|
||||
if _, err = ds.MutatePresets(account, device, func(presets []models.ServicePreset) ([]models.ServicePreset, error) {
|
||||
for len(presets) < presetNumber {
|
||||
presets = append(presets, models.ServicePreset{})
|
||||
}
|
||||
|
||||
presets[presetNumber-1] = presetObj
|
||||
presets[presetNumber-1] = presetObj
|
||||
|
||||
if err = ds.SavePresets(account, device, presets); err != nil {
|
||||
return presets, nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1780,11 +1811,6 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
recents, err := ds.GetRecents(account, device)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var input recentInput
|
||||
if err := xml.Unmarshal(sourceXML, &input); err != nil {
|
||||
return nil, err
|
||||
@@ -1829,9 +1855,20 @@ func AddRecent(ds *datastore.DataStore, account, device string, sourceXML []byte
|
||||
syncMatchingSource(matchingSrc, input)
|
||||
|
||||
utcTime := parseLastPlayedAt(input.LastPlayedAt)
|
||||
recentObj, recents := updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
|
||||
|
||||
if err := ds.SaveRecents(account, device, recents); err != nil {
|
||||
// Read-mutate-write atomically: a concurrent AddRecent/preset call for
|
||||
// the same device racing this one must not be able to clobber it. See
|
||||
// MutatePresets/MutateRecents for why a plain GetRecents+SaveRecents
|
||||
// isn't safe here.
|
||||
var recentObj *models.ServiceRecent
|
||||
|
||||
if _, err := ds.MutateRecents(account, device, func(recents []models.ServiceRecent) ([]models.ServiceRecent, error) {
|
||||
var updated []models.ServiceRecent
|
||||
|
||||
recentObj, updated = updateOrCreateRecent(recents, input.Name, matchingSrc, input.ContentItemType, input.Location, device, utcTime)
|
||||
|
||||
return updated, nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1859,7 +1896,7 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
|
||||
matchingSrc.SecretType = constants.CredentialTypeToken
|
||||
}
|
||||
|
||||
persistLearnedSource(ds, account, device, sources, matchingSrc)
|
||||
persistLearnedSource(ds, account, device, matchingSrc)
|
||||
}
|
||||
|
||||
return matchingSrc, sourceLearned
|
||||
@@ -2009,26 +2046,24 @@ func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceNam
|
||||
return learned
|
||||
}
|
||||
|
||||
func persistLearnedSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, matchingSrc *models.ConfiguredSource) {
|
||||
updatedSources := make([]models.ConfiguredSource, len(sources))
|
||||
copy(updatedSources, sources)
|
||||
func persistLearnedSource(ds *datastore.DataStore, account, device string, matchingSrc *models.ConfiguredSource) {
|
||||
// Read-mutate-write atomically against the persisted list, not a
|
||||
// snapshot the caller read earlier — AddRecent and UpdatePreset can
|
||||
// both be learning/auto-adding sources for the same device
|
||||
// concurrently, and a plain Get+Save here would silently lose
|
||||
// whichever write landed second.
|
||||
_, err := ds.MutateConfiguredSources(account, device, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
for i := range sources {
|
||||
if sources[i].ID == matchingSrc.ID {
|
||||
sources[i] = *matchingSrc
|
||||
|
||||
found := false
|
||||
|
||||
for i := range updatedSources {
|
||||
if updatedSources[i].ID == matchingSrc.ID {
|
||||
updatedSources[i] = *matchingSrc
|
||||
found = true
|
||||
|
||||
break
|
||||
return sources, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
updatedSources = append(updatedSources, *matchingSrc)
|
||||
}
|
||||
|
||||
if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil {
|
||||
return append(sources, *matchingSrc), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %s", sanitizeLog(device), sanitizeErr(err))
|
||||
}
|
||||
}
|
||||
@@ -2414,7 +2449,6 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
}
|
||||
|
||||
devID := entry.Name()
|
||||
sources, _ := ds.GetConfiguredSources(account, devID)
|
||||
|
||||
newSrc := models.ConfiguredSource{
|
||||
ID: sourceID,
|
||||
@@ -2444,37 +2478,39 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
|
||||
|
||||
PrepareConfiguredSource(&newSrc)
|
||||
|
||||
// Update or append. Most providers are singletons (one account each), so
|
||||
// the same provider replaces the existing entry. STORED_MUSIC is the
|
||||
// exception: each DLNA media server is a separate account (username =
|
||||
// "<UDN>/0"), so it must only replace when the account also matches.
|
||||
// Otherwise registering a second media server overwrites the first, which
|
||||
// then vanishes from /full + /sources and the speaker drops it (only one
|
||||
// media server could ever stay registered).
|
||||
replaced := false
|
||||
// Read-mutate-write atomically against the persisted list, not a
|
||||
// snapshot read before the loop body — see MutateConfiguredSources.
|
||||
_, err := ds.MutateConfiguredSources(account, devID, func(sources []models.ConfiguredSource) ([]models.ConfiguredSource, error) {
|
||||
// Update or append. Most providers are singletons (one account
|
||||
// each), so the same provider replaces the existing entry.
|
||||
// STORED_MUSIC is the exception: each DLNA media server is a
|
||||
// separate account (username = "<UDN>/0"), so it must only
|
||||
// replace when the account also matches. Otherwise registering
|
||||
// a second media server overwrites the first, which then
|
||||
// vanishes from /full + /sources and the speaker drops it
|
||||
// (only one media server could ever stay registered).
|
||||
for i := range sources {
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity
|
||||
// (SourceKey.Account), not Username, which does not
|
||||
// round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
}
|
||||
|
||||
for i := range sources {
|
||||
sameProvider := sources[i].SourceProviderID == providerID
|
||||
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
|
||||
// Match on the persisted account identity (SourceKey.Account),
|
||||
// not Username, which does not round-trip through the datastore.
|
||||
sameProvider = sameProvider && sources[i].SourceKey.Account == username
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
|
||||
return sources, nil
|
||||
}
|
||||
}
|
||||
|
||||
if sameProvider ||
|
||||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
|
||||
sources[i] = newSrc
|
||||
replaced = true
|
||||
|
||||
break
|
||||
}
|
||||
return append(sources, newSrc), nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[Marge] AddSource: failed to save source %s for device %s: %s", sanitizeLog(newSrc.SourceKey.Type), sanitizeLog(devID), sanitizeErr(err))
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
sources = append(sources, newSrc)
|
||||
}
|
||||
|
||||
_ = ds.SaveConfiguredSources(account, devID, sources)
|
||||
}
|
||||
|
||||
return sourceID, nil
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestIssue234_FactoryResetSpeakerSyncsReducedSources(t *testing.T) {
|
||||
// SyncDeviceData derives accountID/deviceID from /info; with
|
||||
// an empty margeAccountUUID the account falls through to
|
||||
// "default".
|
||||
if err := m.SyncDeviceData(deviceIP); err != nil {
|
||||
if _, err := m.SyncDeviceData(deviceIP, false); err != nil {
|
||||
t.Fatalf("SyncDeviceData: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+244
-52
@@ -2635,12 +2635,51 @@ func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
|
||||
ErrResolvedFromServiceOnly, host, resolved)
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
|
||||
func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// SyncResourceDiff describes what a Data Sync would change for one
|
||||
// datastore resource (presets or recents): what's currently stored versus
|
||||
// what the speaker's own live :8090 API returned just now.
|
||||
type SyncResourceDiff struct {
|
||||
Resource string `json:"resource"`
|
||||
CurrentCount int `json:"currentCount"`
|
||||
IncomingCount int `json:"incomingCount"`
|
||||
Removed []string `json:"removed,omitempty"`
|
||||
Destructive bool `json:"destructive"`
|
||||
}
|
||||
|
||||
// SyncResult is the outcome of a SyncDeviceData call: whether it actually
|
||||
// wrote anything, and the per-resource diff that led to that decision.
|
||||
type SyncResult struct {
|
||||
Applied bool `json:"applied"`
|
||||
Destructive bool `json:"destructive"`
|
||||
Diffs []SyncResourceDiff `json:"diffs"`
|
||||
// SourcesCount is the number of configured sources saved for this
|
||||
// device, or -1 if the sources fetch failed. Sources are synced
|
||||
// unconditionally (see syncSources) — there's no diff/confirm gate for
|
||||
// them — so this is a plain count rather than a SyncResourceDiff.
|
||||
SourcesCount int `json:"sourcesCount"`
|
||||
}
|
||||
|
||||
// SyncDeviceData fetches presets, recents and sources from the device and
|
||||
// saves them to the datastore.
|
||||
//
|
||||
// Presets and recents are fetched live from the speaker's own :8090 API and
|
||||
// would previously overwrite the datastore unconditionally — including with
|
||||
// an empty or shrunk list if the speaker's own local cache happened to be
|
||||
// stale or incomplete at that exact moment (e.g. right after a burst of
|
||||
// preset writes, or shortly after a reboot before the speaker has resynced
|
||||
// with Marge). That's a real, confirmed mechanism for #614's "Sync wipes my
|
||||
// presets" reports. Now: if applying would shrink either list relative to
|
||||
// what's already stored, SyncDeviceData does NOT write — it reports the
|
||||
// diff instead — unless confirmed is true. There is no cached "preview"
|
||||
// state: every call (confirmed or not) re-fetches live from the speaker at
|
||||
// that moment, so confirming re-checks reality rather than replaying a
|
||||
// possibly-stale earlier snapshot. Sources are left unconditional, as
|
||||
// before — a source-list change is comparatively low-risk and self-healing.
|
||||
func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, error) {
|
||||
// 1. Fetch info to get Serial Number (account identifier)
|
||||
info, err := m.GetLiveDeviceInfo(deviceIP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get device info: %w", err)
|
||||
return SyncResult{}, fmt.Errorf("failed to get device info: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'",
|
||||
@@ -2652,7 +2691,7 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
deviceID := info.DeviceID
|
||||
if deviceID == "" {
|
||||
log.Printf("No deviceID found in /info response for device '%s' at %s", sanitizeLog(info.Name), sanitizeLog(deviceIP))
|
||||
return fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
return SyncResult{}, fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
|
||||
}
|
||||
|
||||
log.Printf("Using deviceID '%s' for sync operations (MAC address from /info)", sanitizeLog(deviceID))
|
||||
@@ -2676,14 +2715,42 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
accountID = "default"
|
||||
}
|
||||
|
||||
// 2. Fetch Presets from :8090
|
||||
m.syncPresets(deviceIP, accountID, deviceID)
|
||||
// 2. Diff presets and recents against a fresh live fetch, before writing
|
||||
// anything.
|
||||
presetDiff, incomingPresets, presetErr := m.presetSyncDiff(deviceIP, accountID, deviceID)
|
||||
if presetErr != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), presetErr)
|
||||
}
|
||||
|
||||
// 3. Fetch Recents from :8090
|
||||
m.syncRecents(deviceIP, accountID, deviceID)
|
||||
recentDiff, incomingRecents, recentErr := m.recentSyncDiff(deviceIP, accountID, deviceID)
|
||||
if recentErr != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch recents for %s: %v", sanitizeLog(deviceIP), recentErr)
|
||||
}
|
||||
|
||||
result := SyncResult{
|
||||
Diffs: []SyncResourceDiff{presetDiff, recentDiff},
|
||||
Destructive: presetDiff.Destructive || recentDiff.Destructive,
|
||||
}
|
||||
|
||||
if result.Destructive && !confirmed {
|
||||
log.Printf("[SYNC] Sync for %s would shrink stored data (presets %d->%d, recents %d->%d) — awaiting confirmation, not writing anything",
|
||||
sanitizeLog(deviceIP), presetDiff.CurrentCount, presetDiff.IncomingCount, recentDiff.CurrentCount, recentDiff.IncomingCount)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 3. Apply presets/recents (skip whichever one failed to fetch, leaving
|
||||
// the existing stored data untouched rather than wiping it).
|
||||
if presetErr == nil {
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, incomingPresets)
|
||||
}
|
||||
|
||||
if recentErr == nil {
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, incomingRecents)
|
||||
}
|
||||
|
||||
// 4. Fetch Sources
|
||||
m.syncSources(deviceIP, accountID, deviceID)
|
||||
result.SourcesCount = m.syncSources(deviceIP, accountID, deviceID)
|
||||
|
||||
// 5. Nudge the device to re-render its source list. After a factory
|
||||
// reset (issue #234) the speaker's /sources only lists the always-on
|
||||
@@ -2697,28 +2764,113 @@ func (m *Manager) SyncDeviceData(deviceIP string) error {
|
||||
// 6. Create off-device backup of system configuration
|
||||
_ = m.BackupConfigOffDevice(deviceIP)
|
||||
|
||||
return nil
|
||||
result.Applied = true
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
// presetSyncDiff fetches the live preset list from the speaker and compares
|
||||
// it against what's currently stored, without writing anything.
|
||||
func (m *Manager) presetSyncDiff(deviceIP, accountID, deviceID string) (SyncResourceDiff, []models.ServicePreset, error) {
|
||||
current, _ := m.DataStore.GetPresets(accountID, deviceID)
|
||||
|
||||
incoming, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
return SyncResourceDiff{Resource: "presets", CurrentCount: len(current), IncomingCount: len(current)}, nil, err
|
||||
}
|
||||
|
||||
return diffPresets(current, incoming), incoming, nil
|
||||
}
|
||||
|
||||
// recentSyncDiff fetches the live recents list from the speaker and
|
||||
// compares it against what's currently stored, without writing anything.
|
||||
func (m *Manager) recentSyncDiff(deviceIP, accountID, deviceID string) (SyncResourceDiff, []models.ServiceRecent, error) {
|
||||
current, _ := m.DataStore.GetRecents(accountID, deviceID)
|
||||
|
||||
incoming, err := m.fetchLiveRecents(deviceIP)
|
||||
if err != nil {
|
||||
return SyncResourceDiff{Resource: "recents", CurrentCount: len(current), IncomingCount: len(current)}, nil, err
|
||||
}
|
||||
|
||||
return diffRecents(current, incoming), incoming, nil
|
||||
}
|
||||
|
||||
// diffPresets compares a stored preset list against a freshly-fetched one.
|
||||
// Removed lists the names of presets present in current but absent (by
|
||||
// button/slot ID) from incoming — this is what tells an operator "Sync
|
||||
// would remove preset 6: Ici Roussillon" instead of just a bare count.
|
||||
func diffPresets(current, incoming []models.ServicePreset) SyncResourceDiff {
|
||||
incomingIDs := make(map[string]bool, len(incoming))
|
||||
for i := range incoming {
|
||||
if incoming[i].ID != "" {
|
||||
incomingIDs[incoming[i].ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
var removed []string
|
||||
|
||||
for i := range current {
|
||||
if current[i].ID != "" && current[i].Name != "" && !incomingIDs[current[i].ID] {
|
||||
removed = append(removed, current[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResourceDiff{
|
||||
Resource: "presets",
|
||||
CurrentCount: len(current),
|
||||
IncomingCount: len(incoming),
|
||||
Removed: removed,
|
||||
Destructive: len(incoming) < len(current),
|
||||
}
|
||||
}
|
||||
|
||||
// diffRecents compares a stored recents list against a freshly-fetched one.
|
||||
// Recents have no stable per-entry ID the way presets do (they're an
|
||||
// ordered, time-sorted, size-capped list), so entries are matched by
|
||||
// content Location instead.
|
||||
func diffRecents(current, incoming []models.ServiceRecent) SyncResourceDiff {
|
||||
incomingLocations := make(map[string]bool, len(incoming))
|
||||
for i := range incoming {
|
||||
if incoming[i].Location != "" {
|
||||
incomingLocations[incoming[i].Location] = true
|
||||
}
|
||||
}
|
||||
|
||||
var removed []string
|
||||
|
||||
for i := range current {
|
||||
if current[i].Location != "" && current[i].Name != "" && !incomingLocations[current[i].Location] {
|
||||
removed = append(removed, current[i].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncResourceDiff{
|
||||
Resource: "recents",
|
||||
CurrentCount: len(current),
|
||||
IncomingCount: len(incoming),
|
||||
Removed: removed,
|
||||
Destructive: len(incoming) < len(current),
|
||||
}
|
||||
}
|
||||
|
||||
// fetchLivePresets fetches the current preset list straight from the
|
||||
// speaker's own local :8090 API. It does not touch the datastore.
|
||||
func (m *Manager) fetchLivePresets(deviceIP string) ([]models.ServicePreset, error) {
|
||||
presetsURL := fmt.Sprintf("http://%s:8090/presets", deviceIP)
|
||||
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
|
||||
presetsURL = fmt.Sprintf("http://%s/presets", deviceIP)
|
||||
}
|
||||
|
||||
log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))
|
||||
|
||||
resp, err := m.HTTPGet(presetsURL)
|
||||
if err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var ps models.Presets
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var servicePresets []models.ServicePreset
|
||||
@@ -2762,10 +2914,28 @@ func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, servicePresets)
|
||||
return servicePresets, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
// syncPresets fetches the live preset list and unconditionally persists it.
|
||||
// Used directly by tests exercising the raw fetch+save behaviour; the
|
||||
// button-driven path goes through SyncDeviceData's diff/confirm guard
|
||||
// instead.
|
||||
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
|
||||
log.Printf("[SYNC] Syncing presets for %s", sanitizeLog(deviceIP))
|
||||
|
||||
presets, err := m.fetchLivePresets(deviceIP)
|
||||
if err != nil {
|
||||
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = m.DataStore.SavePresets(accountID, deviceID, presets)
|
||||
}
|
||||
|
||||
// fetchLiveRecents fetches the current recents list straight from the
|
||||
// speaker's own local :8090 API. It does not touch the datastore.
|
||||
func (m *Manager) fetchLiveRecents(deviceIP string) ([]models.ServiceRecent, error) {
|
||||
recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP)
|
||||
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
|
||||
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
|
||||
@@ -2773,14 +2943,14 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
|
||||
resp, err := m.HTTPGet(recentsURL)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var rr models.RecentsResponse
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil {
|
||||
return
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
var serviceRecents []models.ServiceRecent
|
||||
@@ -2807,10 +2977,28 @@ func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
})
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents)
|
||||
return serviceRecents, nil
|
||||
}
|
||||
|
||||
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
// syncRecents fetches the live recents list and unconditionally persists
|
||||
// it. Used directly by tests exercising the raw fetch+save behaviour; the
|
||||
// button-driven path goes through SyncDeviceData's diff/confirm guard
|
||||
// instead.
|
||||
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
|
||||
recents, err := m.fetchLiveRecents(deviceIP)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveRecents(accountID, deviceID, recents)
|
||||
}
|
||||
|
||||
// syncSources fetches the device's configured sources (via SSH first, then
|
||||
// falling back to :8090/sources) and persists them. It returns the number
|
||||
// of sources actually saved, or -1 if neither path produced anything to
|
||||
// save (so the caller/UI can distinguish "synced zero sources" from "sync
|
||||
// didn't run").
|
||||
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) int {
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
|
||||
@@ -2835,7 +3023,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
|
||||
|
||||
return
|
||||
return len(srs.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2847,46 +3035,50 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
|
||||
|
||||
resp, err := m.HTTPGet(sourcesURL)
|
||||
if err != nil {
|
||||
return
|
||||
return -1
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var srs models.Sources
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
|
||||
var configuredSources []models.ConfiguredSource
|
||||
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
for _, s := range srs.SourceItem {
|
||||
cs := models.ConfiguredSource{
|
||||
DisplayName: s.DisplayName,
|
||||
Secret: "",
|
||||
SecretType: "",
|
||||
}
|
||||
if s.Status == "READY" {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
var configuredSources []models.ConfiguredSource
|
||||
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
cs.SourceKey.Type = s.Source
|
||||
cs.SourceKey.Account = s.SourceAccount
|
||||
// Also set legacy fields for now
|
||||
cs.SourceKeyType = s.Source
|
||||
cs.SourceKeyAccount = s.SourceAccount
|
||||
|
||||
configuredSources = append(configuredSources, cs)
|
||||
for _, s := range srs.SourceItem {
|
||||
cs := models.ConfiguredSource{
|
||||
DisplayName: s.DisplayName,
|
||||
Secret: "",
|
||||
SecretType: "",
|
||||
}
|
||||
if s.Status == "READY" {
|
||||
cs.SecretType = "token"
|
||||
}
|
||||
|
||||
// Drop device-local/transient sources without a resolvable
|
||||
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
|
||||
// Persisting them causes /full to emit an empty <sourceproviderid>
|
||||
// which the speaker rejects as INVALID_SOURCE (#334).
|
||||
configuredSources = filterServableSources(configuredSources, deviceID)
|
||||
if s.Source == constants.ProviderSpotify {
|
||||
cs.SecretType = "token_version_3"
|
||||
}
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
cs.SourceKey.Type = s.Source
|
||||
cs.SourceKey.Account = s.SourceAccount
|
||||
// Also set legacy fields for now
|
||||
cs.SourceKeyType = s.Source
|
||||
cs.SourceKeyAccount = s.SourceAccount
|
||||
|
||||
configuredSources = append(configuredSources, cs)
|
||||
}
|
||||
|
||||
// Drop device-local/transient sources without a resolvable
|
||||
// sourceproviderid (e.g. STORED_MUSIC_MEDIA_RENDERER, UPNP).
|
||||
// Persisting them causes /full to emit an empty <sourceproviderid>
|
||||
// which the speaker rejects as INVALID_SOURCE (#334).
|
||||
configuredSources = filterServableSources(configuredSources, deviceID)
|
||||
|
||||
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, configuredSources)
|
||||
|
||||
return len(configuredSources)
|
||||
}
|
||||
|
||||
// filterServableSources returns a copy of srcs containing only sources that
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// TestSyncDeviceData_DestructiveSyncRequiresConfirmation is a regression
|
||||
// test for #614's 2026-08-23 finding: SyncDeviceData used to overwrite the
|
||||
// datastore unconditionally with whatever the speaker's live :8090 API
|
||||
// returned, even if that snapshot had fewer presets than what was already
|
||||
// stored — e.g. because the speaker's own local cache was stale or
|
||||
// incomplete at that exact moment. This is a real, confirmed mechanism for
|
||||
// "Sync wipes my presets" reports.
|
||||
//
|
||||
// A device already has 3 stored presets. The mock speaker's live /presets
|
||||
// only reports 1. The first (unconfirmed) sync must NOT write anything and
|
||||
// must report the shrink; a confirmed retry must apply it.
|
||||
func TestSyncDeviceData_DestructiveSyncRequiresConfirmation(t *testing.T) {
|
||||
const (
|
||||
accountID = "1234567"
|
||||
deviceID = "AABBCCDDEEFF"
|
||||
)
|
||||
|
||||
mockDevice := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/info":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<info deviceID="%s">
|
||||
<name>Test Device</name>
|
||||
<type>SoundTouch 20</type>
|
||||
<margeAccountUUID>%s</margeAccountUUID>
|
||||
</info>`, deviceID, accountID)
|
||||
case "/presets":
|
||||
// Only one preset survived on the speaker's own live cache —
|
||||
// the datastore already has three (seeded below).
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presets>
|
||||
<preset id="1">
|
||||
<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="/custom/v1/playback/station1" isPresetable="true">
|
||||
<itemName>Station 1</itemName>
|
||||
</ContentItem>
|
||||
</preset>
|
||||
</presets>`)
|
||||
case "/recents":
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?><recents></recents>`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer mockDevice.Close()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "sync-destructive-guard-*")
|
||||
if err != nil {
|
||||
t.Fatalf("tempdir: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }()
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
seeded := []models.ServicePreset{
|
||||
{ID: "1", ButtonNumber: "1", ServiceContentItem: models.ServiceContentItem{Name: "Station 1"}},
|
||||
{ID: "2", ButtonNumber: "2", ServiceContentItem: models.ServiceContentItem{Name: "Station 2"}},
|
||||
{ID: "3", ButtonNumber: "3", ServiceContentItem: models.ServiceContentItem{Name: "Station 3"}},
|
||||
}
|
||||
if err := ds.SavePresets(accountID, deviceID, seeded); err != nil {
|
||||
t.Fatalf("seed SavePresets: %v", err)
|
||||
}
|
||||
|
||||
m := NewManager("http://localhost:8000", ds, nil)
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
|
||||
// Unconfirmed: must refuse to write and report the shrink.
|
||||
result, err := m.SyncDeviceData(deviceIP, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData(confirmed=false): %v", err)
|
||||
}
|
||||
|
||||
if result.Applied {
|
||||
t.Fatal("expected unconfirmed destructive sync to NOT apply")
|
||||
}
|
||||
|
||||
if !result.Destructive {
|
||||
t.Fatal("expected result.Destructive=true for a 3->1 preset shrink")
|
||||
}
|
||||
|
||||
var presetDiff *SyncResourceDiff
|
||||
for i := range result.Diffs {
|
||||
if result.Diffs[i].Resource == "presets" {
|
||||
presetDiff = &result.Diffs[i]
|
||||
}
|
||||
}
|
||||
|
||||
if presetDiff == nil {
|
||||
t.Fatal("expected a presets diff in the result")
|
||||
}
|
||||
|
||||
if presetDiff.CurrentCount != 3 || presetDiff.IncomingCount != 1 {
|
||||
t.Errorf("expected presets diff 3->1, got %d->%d", presetDiff.CurrentCount, presetDiff.IncomingCount)
|
||||
}
|
||||
|
||||
if len(presetDiff.Removed) != 2 {
|
||||
t.Errorf("expected 2 removed preset names (slots 2 and 3), got %v", presetDiff.Removed)
|
||||
}
|
||||
|
||||
presetsAfterRefusal, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after refused sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterRefusal) != 3 {
|
||||
t.Fatalf("expected the original 3 presets to survive an unconfirmed destructive sync, got %d", len(presetsAfterRefusal))
|
||||
}
|
||||
|
||||
// Confirmed: must re-check fresh state and apply.
|
||||
result, err = m.SyncDeviceData(deviceIP, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData(confirmed=true): %v", err)
|
||||
}
|
||||
|
||||
if !result.Applied {
|
||||
t.Fatal("expected confirmed destructive sync to apply")
|
||||
}
|
||||
|
||||
presetsAfterConfirm, err := ds.GetPresets(accountID, deviceID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPresets after confirmed sync: %v", err)
|
||||
}
|
||||
|
||||
if len(presetsAfterConfirm) != 1 {
|
||||
t.Fatalf("expected confirmed sync to shrink to 1 preset, got %d", len(presetsAfterConfirm))
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestSyncDeviceData_UsesDeviceID(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestSyncDeviceData_NoDeviceID_ShouldFail(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Test SyncDeviceData - should fail
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err == nil {
|
||||
t.Fatal("SyncDeviceData should have failed when deviceID is empty")
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func TestSyncDeviceData_FallbackToExistingDeviceMapping(t *testing.T) {
|
||||
manager := NewManager("http://localhost:8000", ds, cm)
|
||||
|
||||
// Sync should work and use MAC address
|
||||
err := manager.SyncDeviceData(serverHost)
|
||||
_, err := manager.SyncDeviceData(serverHost, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ func TestSyncSources_Format(t *testing.T) {
|
||||
deviceIP := mockDevice.Listener.Addr().String()
|
||||
accountID := "1234567"
|
||||
deviceID := "001122334455"
|
||||
err = m.SyncDeviceData(deviceIP)
|
||||
_, err = m.SyncDeviceData(deviceIP, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SyncDeviceData failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//go:build browsertest
|
||||
|
||||
// Package soundtouchweb browser-level regression tests for #649. These drive
|
||||
// a real headless Chrome via chromedp (already a project dependency, used
|
||||
// today for the doc-screenshot tool) instead of only asserting on the raw
|
||||
// HTML/JS source. They are opt-in (build tag "browsertest", run via `make
|
||||
// test-browser`) rather than part of the default `go test ./...`/`make
|
||||
// check` path, since they require a Chrome/Chromium binary to be present --
|
||||
// see CONTRIBUTING or the Makefile for how to run them locally or in CI.
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// newHeadlessChromeContext returns a context bound to a fresh headless
|
||||
// Chrome instance, torn down automatically at the end of the test.
|
||||
func newHeadlessChromeContext(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
|
||||
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
|
||||
append(chromedp.DefaultExecAllocatorOptions[:],
|
||||
chromedp.Flag("headless", true),
|
||||
chromedp.Flag("disable-gpu", true),
|
||||
// CI runners commonly execute as a user without the namespace
|
||||
// permissions Chrome's sandbox needs; harmless to also set
|
||||
// locally.
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
)...,
|
||||
)
|
||||
t.Cleanup(cancelAlloc)
|
||||
|
||||
ctx, cancelCtx := chromedp.NewContext(allocCtx)
|
||||
t.Cleanup(cancelCtx)
|
||||
|
||||
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
|
||||
t.Cleanup(cancelTimeout)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestPlayerRendersNatively confirms the shipped page (native import maps,
|
||||
// es-module-shims left uninjected) still renders in an ordinary modern
|
||||
// browser -- i.e. that restoring import maps for #649 didn't break the
|
||||
// common case for the vast majority of users who never need the shim.
|
||||
func TestPlayerRendersNatively(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
r := chi.NewRouter()
|
||||
app.Mount(r, nil)
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
ctx := newHeadlessChromeContext(t)
|
||||
|
||||
var shimInjected bool
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(server.URL+"/app"),
|
||||
chromedp.WaitVisible(`.nav-discover-icon`, chromedp.ByQuery),
|
||||
chromedp.Evaluate(`document.querySelectorAll('script[src*="es-module-shims"]').length > 0`, &shimInjected),
|
||||
); err != nil {
|
||||
t.Fatalf("chromedp run: %v", err)
|
||||
}
|
||||
|
||||
if shimInjected {
|
||||
t.Error("es-module-shims should not be injected on a browser with native import map support")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerRendersUnderForcedShimMode exercises es-module-shims resolving the
|
||||
// same import map and vendored files the real app uses. It does not emulate
|
||||
// Safari or the production feature-detection loader; those require a target-
|
||||
// browser canary. The test serves a page that forces es-module-shims into
|
||||
// shimMode
|
||||
// (see the library's README: shimMode is triggered by
|
||||
// window.esmsInitOptions.shimMode or by using importmap-shim/module-shim
|
||||
// script types), which routes every browser -- including this ordinary
|
||||
// headless Chrome -- through the library's own polyfill resolution instead
|
||||
// of native import map support.
|
||||
func TestPlayerRendersUnderForcedShimMode(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
r := chi.NewRouter()
|
||||
app.MountWeb(r, nil) // only need /app/static/* and /api/control/*
|
||||
|
||||
const shimModePage = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>window.esmsInitOptions = { shimMode: true };</script>
|
||||
<script src="/app/static/lib/es-module-shims.js"></script>
|
||||
<script type="importmap-shim">
|
||||
{
|
||||
"imports": {
|
||||
"preact": "/app/static/lib/preact.module.js",
|
||||
"preact/hooks": "/app/static/lib/preact-hooks.module.js",
|
||||
"htm": "/app/static/lib/htm.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/app/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module-shim" src="/app/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
r.Get("/test-shim-mode", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(shimModePage))
|
||||
})
|
||||
|
||||
server := httptest.NewServer(r)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
ctx := newHeadlessChromeContext(t)
|
||||
|
||||
var rendered bool
|
||||
if err := chromedp.Run(ctx,
|
||||
chromedp.Navigate(server.URL+"/test-shim-mode"),
|
||||
chromedp.WaitVisible(`.nav-discover-icon`, chromedp.ByQuery),
|
||||
chromedp.Evaluate(`document.getElementById('app').children.length > 0`, &rendered),
|
||||
); err != nil {
|
||||
t.Fatalf("chromedp run (forced shim mode): %v", err)
|
||||
}
|
||||
|
||||
if !rendered {
|
||||
t.Error("app did not render under forced es-module-shims shim mode")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
// deviceView is the player-facing representation of one control target.
|
||||
// A stereo pair is projected as one target keyed by its master speaker's host;
|
||||
// the underlying registry continues to track both physical speakers.
|
||||
type deviceView struct {
|
||||
Info *models.DeviceInfo `json:"info"`
|
||||
Status *webtypes.DeviceStatus `json:"status"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
StereoPair *stereoPairView `json:"stereoPair,omitempty"`
|
||||
}
|
||||
|
||||
// deviceProjectionEntry captures one immutable status pointer per physical
|
||||
// device. Projection must not re-read live connection state midway through
|
||||
// building a response, otherwise group membership and the emitted status can
|
||||
// describe different moments.
|
||||
type deviceProjectionEntry struct {
|
||||
ID string
|
||||
Info *models.DeviceInfo
|
||||
Status *webtypes.DeviceStatus
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// stereoPairView describes the physical members represented by a logical
|
||||
// player target. Controls are always sent to MasterDeviceID via the map key.
|
||||
type stereoPairView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MasterDeviceID string `json:"masterDeviceId"`
|
||||
Status string `json:"status,omitempty"`
|
||||
MemberCount int `json:"memberCount"`
|
||||
AvailableMemberCount int `json:"availableMemberCount"`
|
||||
Degraded bool `json:"degraded"`
|
||||
Members []stereoPairMemberView `json:"members"`
|
||||
}
|
||||
|
||||
// stereoPairMemberView is the player-facing role and availability of one
|
||||
// physical speaker in a stereo pair.
|
||||
type stereoPairMemberView struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
Role string `json:"role"`
|
||||
IPAddress string `json:"ipAddress,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// stereoPairCapable reports whether info's model supports stereo pairing
|
||||
// (ST-10 only). This must stay a model-name check rather than a runtime
|
||||
// capability probe: verified against real hardware, a SoundTouch 20 lists
|
||||
// /getGroup (and /addGroup, /removeGroup, /updateGroup) in its own
|
||||
// /supportedURLs response even though the device doesn't actually reply to
|
||||
// /getGroup -- see Client.GetGroup's doc comment. The device's supportedURLs
|
||||
// listing reflects firmware-level route registration, not per-model feature
|
||||
// support, so it cannot be used to detect stereo-pair capability either.
|
||||
func stereoPairCapable(info *models.DeviceInfo) bool {
|
||||
if info == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
typeName := strings.ToLower(strings.TrimSpace(info.Type))
|
||||
|
||||
return typeName == "st10" || typeName == "soundtouch 10"
|
||||
}
|
||||
|
||||
// deviceViewSnapshot projects the physical registry into logical control
|
||||
// targets for the HTTP API and the global player WebSocket.
|
||||
func (app *WebApp) deviceViewSnapshot() map[string]deviceView {
|
||||
return projectDeviceEntries(app.DeviceSnapshot())
|
||||
}
|
||||
|
||||
func projectDeviceEntries(snapshot []DeviceEntry) map[string]deviceView {
|
||||
return projectCapturedDeviceEntries(captureDeviceProjectionEntries(snapshot))
|
||||
}
|
||||
|
||||
func captureDeviceProjectionEntries(snapshot []DeviceEntry) []deviceProjectionEntry {
|
||||
captured := make([]deviceProjectionEntry, 0, len(snapshot))
|
||||
for _, entry := range snapshot {
|
||||
if entry.Device == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
captured = append(captured, deviceProjectionEntry{
|
||||
ID: entry.ID,
|
||||
Info: entry.Device.DeviceInfo,
|
||||
Status: entry.Device.Status(),
|
||||
LastSeen: entry.LastSeen,
|
||||
})
|
||||
}
|
||||
|
||||
return captured
|
||||
}
|
||||
|
||||
func projectCapturedDeviceEntries(snapshot []deviceProjectionEntry) map[string]deviceView {
|
||||
byDeviceID := make(map[string][]deviceProjectionEntry, len(snapshot))
|
||||
for _, entry := range snapshot {
|
||||
if entry.Info == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceID := strings.TrimSpace(entry.Info.DeviceID)
|
||||
if deviceID != "" {
|
||||
byDeviceID[deviceID] = append(byDeviceID[deviceID], entry)
|
||||
}
|
||||
}
|
||||
|
||||
masters := make(map[string]*stereoPairView)
|
||||
hidden := make(map[string]bool)
|
||||
|
||||
for _, entry := range snapshot {
|
||||
if entry.Info == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if entry.Status == nil || !validMasterGroup(entry.Info.DeviceID, entry.Status.Group) {
|
||||
continue
|
||||
}
|
||||
|
||||
master, unique := uniqueDeviceEntry(byDeviceID, entry.Status.Group.MasterDeviceID)
|
||||
if !unique || master.ID != entry.ID || !registeredMembersAgree(entry.Status.Group, byDeviceID) {
|
||||
continue
|
||||
}
|
||||
|
||||
pair := newStereoPairView(entry.Status.Group, byDeviceID)
|
||||
masters[entry.ID] = pair
|
||||
|
||||
for _, role := range entry.Status.Group.Roles.Roles {
|
||||
member, ok := uniqueDeviceEntry(byDeviceID, role.DeviceID)
|
||||
if ok && member.ID != entry.ID {
|
||||
hidden[member.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices := make(map[string]deviceView, len(snapshot))
|
||||
for _, entry := range snapshot {
|
||||
if hidden[entry.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
pair := masters[entry.ID]
|
||||
devices[entry.ID] = deviceView{
|
||||
Info: projectedDeviceInfo(entry.Info, pair),
|
||||
Status: entry.Status,
|
||||
LastSeen: entry.LastSeen,
|
||||
StereoPair: pair,
|
||||
}
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func validMasterGroup(deviceID string, group *models.Group) bool {
|
||||
if group == nil || group.IsEmpty() || strings.TrimSpace(group.ID) == "" ||
|
||||
strings.TrimSpace(group.MasterDeviceID) == "" || len(group.Roles.Roles) != 2 ||
|
||||
strings.TrimSpace(deviceID) != strings.TrimSpace(group.MasterDeviceID) {
|
||||
return false
|
||||
}
|
||||
|
||||
seenDevices := make(map[string]bool, len(group.Roles.Roles))
|
||||
seenRoles := make(map[string]bool, len(group.Roles.Roles))
|
||||
masterPresent := false
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
memberID := strings.TrimSpace(role.DeviceID)
|
||||
|
||||
memberRole := strings.ToUpper(strings.TrimSpace(role.Role))
|
||||
if memberID == "" || seenDevices[memberID] || (memberRole != "LEFT" && memberRole != "RIGHT") || seenRoles[memberRole] {
|
||||
return false
|
||||
}
|
||||
|
||||
seenDevices[memberID] = true
|
||||
seenRoles[memberRole] = true
|
||||
masterPresent = masterPresent || memberID == strings.TrimSpace(group.MasterDeviceID)
|
||||
}
|
||||
|
||||
return masterPresent && seenRoles["LEFT"] && seenRoles["RIGHT"]
|
||||
}
|
||||
|
||||
func uniqueDeviceEntry(byDeviceID map[string][]deviceProjectionEntry, deviceID string) (deviceProjectionEntry, bool) {
|
||||
entries := byDeviceID[strings.TrimSpace(deviceID)]
|
||||
if len(entries) != 1 {
|
||||
return deviceProjectionEntry{}, false
|
||||
}
|
||||
|
||||
return entries[0], true
|
||||
}
|
||||
|
||||
func registeredMembersAgree(group *models.Group, byDeviceID map[string][]deviceProjectionEntry) bool {
|
||||
for _, role := range group.Roles.Roles {
|
||||
entries := byDeviceID[strings.TrimSpace(role.DeviceID)]
|
||||
if len(entries) > 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if entries[0].Status == nil || !models.SameGroup(group, entries[0].Status.Group) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func newStereoPairView(group *models.Group, byDeviceID map[string][]deviceProjectionEntry) *stereoPairView {
|
||||
members := make([]stereoPairMemberView, 0, len(group.Roles.Roles))
|
||||
available := 0
|
||||
|
||||
for _, role := range group.Roles.Roles {
|
||||
member := stereoPairMemberView{
|
||||
DeviceID: role.DeviceID,
|
||||
Role: role.Role,
|
||||
IPAddress: role.IPAddress,
|
||||
}
|
||||
|
||||
if entry, ok := uniqueDeviceEntry(byDeviceID, role.DeviceID); ok {
|
||||
if entry.Info != nil {
|
||||
member.Name = entry.Info.Name
|
||||
if entry.Info.IPAddress != "" {
|
||||
member.IPAddress = entry.Info.IPAddress
|
||||
}
|
||||
}
|
||||
|
||||
member.Available = entry.Status != nil && entry.Status.IsConnected
|
||||
if member.Available {
|
||||
available++
|
||||
}
|
||||
}
|
||||
|
||||
members = append(members, member)
|
||||
}
|
||||
|
||||
return &stereoPairView{
|
||||
ID: group.ID,
|
||||
Name: logicalPairName(group.Name, members),
|
||||
MasterDeviceID: group.MasterDeviceID,
|
||||
Status: group.Status,
|
||||
MemberCount: len(members),
|
||||
AvailableMemberCount: available,
|
||||
Degraded: available != len(members) || (group.Status != "" && group.Status != "GROUP_OK"),
|
||||
Members: members,
|
||||
}
|
||||
}
|
||||
|
||||
func projectedDeviceInfo(info *models.DeviceInfo, pair *stereoPairView) *models.DeviceInfo {
|
||||
if info == nil || pair == nil || pair.Name == "" || pair.Name == info.Name {
|
||||
return info
|
||||
}
|
||||
|
||||
projected := *info
|
||||
projected.Name = pair.Name
|
||||
|
||||
return &projected
|
||||
}
|
||||
|
||||
func logicalPairName(groupName string, members []stereoPairMemberView) string {
|
||||
commonName := ""
|
||||
|
||||
for _, member := range members {
|
||||
name := strings.TrimSpace(member.Name)
|
||||
if name == "" {
|
||||
return groupName
|
||||
}
|
||||
|
||||
if commonName == "" {
|
||||
commonName = name
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.EqualFold(commonName, name) {
|
||||
return groupName
|
||||
}
|
||||
}
|
||||
|
||||
if commonName != "" {
|
||||
return commonName
|
||||
}
|
||||
|
||||
return groupName
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
func projectionDevice(host, deviceID, name string, connected bool, group *models.Group) DeviceEntry {
|
||||
conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{
|
||||
DeviceID: deviceID,
|
||||
Name: name,
|
||||
IPAddress: host,
|
||||
})
|
||||
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: connected, Group: group})
|
||||
|
||||
return DeviceEntry{ID: host, Device: conn, LastSeen: conn.LastSeen}
|
||||
}
|
||||
|
||||
func testStereoGroup() *models.Group {
|
||||
return &models.Group{
|
||||
ID: "pair-1",
|
||||
Name: "Living Room + Living Room",
|
||||
MasterDeviceID: "left-id",
|
||||
Status: "GROUP_OK",
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: "left-id", Role: "LEFT", IPAddress: "192.0.2.10"},
|
||||
{DeviceID: "right-id", Role: "RIGHT", IPAddress: "192.0.2.11"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesCollapsesStereoPairUnderMaster(t *testing.T) {
|
||||
group := testStereoGroup()
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
})
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("projected devices = %d, want one logical stereo target: %+v", len(got), got)
|
||||
}
|
||||
|
||||
master, ok := got["192.0.2.10"]
|
||||
if !ok {
|
||||
t.Fatalf("master control target missing: %+v", got)
|
||||
}
|
||||
|
||||
if master.StereoPair == nil {
|
||||
t.Fatal("master is missing stereo-pair metadata")
|
||||
}
|
||||
|
||||
if master.StereoPair.MemberCount != 2 || master.StereoPair.AvailableMemberCount != 2 || master.StereoPair.Degraded {
|
||||
t.Errorf("unexpected pair availability: %+v", master.StereoPair)
|
||||
}
|
||||
|
||||
if master.Info.Name != "Living Room" || master.StereoPair.Name != "Living Room" {
|
||||
t.Errorf("logical pair name was not projected consistently: %+v", master)
|
||||
}
|
||||
|
||||
if _, ok := got["192.0.2.11"]; ok {
|
||||
t.Error("physical right member must not be a second control target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesShowsDegradedPairWhenMemberIsMissing(t *testing.T) {
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, testStereoGroup()),
|
||||
})
|
||||
|
||||
pair := got["192.0.2.10"].StereoPair
|
||||
if pair == nil {
|
||||
t.Fatal("connected master should remain a logical pair when its member is unavailable")
|
||||
}
|
||||
|
||||
if pair.AvailableMemberCount != 1 || !pair.Degraded {
|
||||
t.Errorf("missing member not reflected as degraded: %+v", pair)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesKeepsStablePairWhenMasterIsDisconnected(t *testing.T) {
|
||||
group := testStereoGroup()
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", false, group),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
})
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("projected devices = %d, want a stable logical pair while its master is registered", len(got))
|
||||
}
|
||||
|
||||
pair := got["192.0.2.10"].StereoPair
|
||||
if pair == nil || !pair.Degraded || pair.AvailableMemberCount != 1 {
|
||||
t.Errorf("disconnected master should produce a degraded logical pair: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesLeavesMemberPhysicalWhenMasterIsAbsent(t *testing.T) {
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, testStereoGroup()),
|
||||
})
|
||||
|
||||
if len(got) != 1 || got["192.0.2.11"].StereoPair != nil {
|
||||
t.Fatalf("member without a registered master must remain a physical target: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesRequiresMasterReportedGroup(t *testing.T) {
|
||||
group := testStereoGroup()
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, nil),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("slave-only group data must not collapse the registry: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesRejectsMalformedGroup(t *testing.T) {
|
||||
group := testStereoGroup()
|
||||
group.Roles.Roles[1].DeviceID = group.Roles.Roles[0].DeviceID
|
||||
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("malformed pair must not hide a physical device: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectDeviceEntriesRejectsConflictingMemberClaim(t *testing.T) {
|
||||
masterGroup := testStereoGroup()
|
||||
memberGroup := testStereoGroup()
|
||||
memberGroup.ID = "different-pair"
|
||||
|
||||
got := projectDeviceEntries([]DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, masterGroup),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, memberGroup),
|
||||
})
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("conflicting pair claims must fail open: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCapturedDeviceEntriesUsesOneCoherentStatusPerDevice(t *testing.T) {
|
||||
group := testStereoGroup()
|
||||
entries := []DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
}
|
||||
captured := captureDeviceProjectionEntries(entries)
|
||||
|
||||
entries[0].Device.ApplyGroupEvent(&models.Group{}, time.Now())
|
||||
entries[1].Device.ApplyGroupEvent(&models.Group{}, time.Now())
|
||||
|
||||
got := projectCapturedDeviceEntries(captured)
|
||||
master := got["192.0.2.10"]
|
||||
if master.StereoPair == nil || master.Status == nil || master.Status.Group == nil || master.Status.Group.ID != "pair-1" {
|
||||
t.Fatalf("captured projection mixed newer connection state into its response: %+v", got)
|
||||
}
|
||||
|
||||
if fresh := projectDeviceEntries(entries); len(fresh) != 2 {
|
||||
t.Fatalf("fresh projection did not observe the cleared group: %+v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceViewSnapshotConcurrentTouchUsesCapturedLastSeen(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
conn := newRegistryDevice("Living Room")
|
||||
if !app.AddDevice("192.0.2.10", conn) {
|
||||
t.Fatal("AddDevice returned false on first insert")
|
||||
}
|
||||
|
||||
stale := app.DeviceSnapshot()
|
||||
if len(stale) != 1 {
|
||||
t.Fatalf("DeviceSnapshot len = %d, want 1", len(stale))
|
||||
}
|
||||
|
||||
if !app.TouchDevice("192.0.2.10") {
|
||||
t.Fatal("TouchDevice returned false for registered device")
|
||||
}
|
||||
if got := projectDeviceEntries(stale)["192.0.2.10"].LastSeen; got != stale[0].LastSeen {
|
||||
t.Fatalf("projection LastSeen = %s, want captured value %s", got, stale[0].LastSeen)
|
||||
}
|
||||
|
||||
const iterations = 1000
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for i := 0; i < iterations; i++ {
|
||||
app.TouchDevice("192.0.2.10")
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
for i := 0; i < iterations; i++ {
|
||||
_ = app.deviceViewSnapshot()
|
||||
}
|
||||
}()
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestHandleAPIDevicesUsesLogicalStereoProjection(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
group := testStereoGroup()
|
||||
for _, entry := range []DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
} {
|
||||
app.AddDevice(entry.ID, entry.Device)
|
||||
}
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
app.HandleAPIDevices(response, httptest.NewRequest("GET", "/api/control/devices", nil))
|
||||
|
||||
var payload struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]deviceView `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode devices response: %v", err)
|
||||
}
|
||||
|
||||
if response.Code != http.StatusOK || !payload.Success || len(payload.Data) != 1 {
|
||||
t.Fatalf("unexpected devices response: status=%d payload=%+v", response.Code, payload)
|
||||
}
|
||||
|
||||
if pair := payload.Data["192.0.2.10"].StereoPair; pair == nil || pair.ID != "pair-1" || pair.MemberCount != 2 {
|
||||
t.Fatalf("logical stereo metadata missing from devices API: %+v", payload.Data)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package soundtouchweb
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -10,12 +11,18 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/speaker"
|
||||
)
|
||||
|
||||
// NewDiscoveryService loads config and returns a unified discovery service
|
||||
// preconfigured for the web UI's use (10 s discovery timeout, cache on).
|
||||
// When discoveryInterface is non-empty, mDNS/UPnP are pinned to that NIC.
|
||||
func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryService {
|
||||
// configuredHosts (e.g. from --devices) are folded into cfg.PreferredDevices
|
||||
// alongside any already loaded from PREFERRED_DEVICES (deduplicated by
|
||||
// host), so they're retried on every subsequent DiscoverDevices pass, not
|
||||
// just once at startup -- a host that's offline now still gets picked up
|
||||
// once it comes online.
|
||||
func NewDiscoveryService(discoveryInterface string, configuredHosts ...string) *discovery.UnifiedDiscoveryService {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
@@ -30,6 +37,23 @@ func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryS
|
||||
cfg.DiscoveryInterface = discoveryInterface
|
||||
}
|
||||
|
||||
existingHosts := make(map[string]bool, len(cfg.PreferredDevices))
|
||||
for _, d := range cfg.PreferredDevices {
|
||||
existingHosts[d.Host] = true
|
||||
}
|
||||
|
||||
for _, host := range configuredHosts {
|
||||
if host == "" || existingHosts[host] {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg.PreferredDevices = append(cfg.PreferredDevices, config.DeviceConfig{
|
||||
Host: host,
|
||||
Port: speaker.HTTPPort,
|
||||
})
|
||||
existingHosts[host] = true
|
||||
}
|
||||
|
||||
return discovery.NewUnifiedDiscoveryService(cfg)
|
||||
}
|
||||
|
||||
@@ -40,9 +64,13 @@ func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryS
|
||||
// mDNS/UPnP. If the host is already known, the existing entry's
|
||||
// LastSeen is bumped and the function returns without re-fetching.
|
||||
func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
|
||||
app.addDeviceByHost(host, port, source)
|
||||
}
|
||||
|
||||
func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtypes.DeviceConnection {
|
||||
// Fast path: skip the network call if we already know this host.
|
||||
if app.TouchDevice(host) {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
c := client.NewClient(&client.Config{
|
||||
@@ -54,7 +82,7 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
|
||||
info, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch device info from %s (%s): %v", sanitizeLog(host), sanitizeLog(source), err)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure IPAddress is set for the web UI
|
||||
@@ -67,7 +95,7 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
|
||||
// Lost a race — another goroutine inserted the same host
|
||||
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
|
||||
// on the existing entry; discard our conn.
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
go app.UpdateDeviceStatus(host, conn)
|
||||
@@ -90,10 +118,13 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
|
||||
}()
|
||||
|
||||
log.Printf("Added %s device %s (%s) at %s:%d", sanitizeLog(source), sanitizeLog(info.Name), sanitizeLog(info.Type), sanitizeLog(host), port)
|
||||
|
||||
return conn
|
||||
}
|
||||
|
||||
// SeedExtraDevices registers any devices reported by the ExtraDeviceHosts hook
|
||||
// (if set) via AddDeviceByHost. Idempotent: already-known hosts are skipped.
|
||||
// (if set) via AddDeviceByHost, and prunes any previously-seeded host that no
|
||||
// longer appears in that set. Idempotent: already-known hosts are skipped.
|
||||
// Used by the embedded build to surface the service datastore's devices even
|
||||
// when network discovery is disabled; a no-op for standalone soundtouch-player.
|
||||
//
|
||||
@@ -102,15 +133,56 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
|
||||
// datastore would otherwise stall the whole seed for 10 s, serially. Fanning
|
||||
// out bounds the cost to roughly a single timeout regardless of how many
|
||||
// devices are offline. AddDeviceByHost is registry-safe under concurrency.
|
||||
//
|
||||
// A hook read failure is logged and otherwise swallowed here; callers that
|
||||
// need to distinguish "read failed" from "converged" (the bounded startup
|
||||
// retry) should call seedExtraDevices directly instead.
|
||||
func (app *WebApp) SeedExtraDevices() {
|
||||
if _, _, _, err := app.seedExtraDevices(); err != nil {
|
||||
log.Printf("SeedExtraDevices: failed to read extra device hosts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type seededExtraDevice struct {
|
||||
host string
|
||||
conn *webtypes.DeviceConnection
|
||||
}
|
||||
|
||||
// seedExtraDevices probes any ExtraDeviceHosts hosts that aren't already
|
||||
// registered, and prunes any registered host that's no longer in the current
|
||||
// desired set. Pruning is safe to apply to the whole registry (not just hosts
|
||||
// this call inserted) because ExtraDeviceHosts is the only inserter into this
|
||||
// registry for the embedded build: discoveryService is nil there, so the
|
||||
// mDNS/UPnP insertion path in DiscoverDevices is never reached.
|
||||
//
|
||||
// Runs are serialized via seedMu so the bounded startup retry loop
|
||||
// (SeedExtraDevicesUntilReady) and a devices-changed-hook-triggered
|
||||
// SeedExtraDevices call never issue concurrent probes to the same offline
|
||||
// host.
|
||||
//
|
||||
// A non-nil error means the hook itself failed (e.g. a datastore glitch);
|
||||
// callers must treat that as "unknown state, don't prune, don't declare
|
||||
// ready" rather than as an empty desired set.
|
||||
func (app *WebApp) seedExtraDevices() (inserted []seededExtraDevice, removed int, desired map[string]struct{}, err error) {
|
||||
if app.ExtraDeviceHosts == nil {
|
||||
return
|
||||
return nil, 0, nil, nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
app.seedMu.Lock()
|
||||
defer app.seedMu.Unlock()
|
||||
|
||||
for _, host := range app.ExtraDeviceHosts() {
|
||||
if host == "" {
|
||||
desired, err = app.extraDeviceHostSet()
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
for host := range desired {
|
||||
if _, ok := app.GetDevice(host); ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -119,11 +191,129 @@ func (app *WebApp) SeedExtraDevices() {
|
||||
go func(h string) {
|
||||
defer wg.Done()
|
||||
|
||||
app.AddDeviceByHost(h, 8090, "service-store")
|
||||
conn := app.addDeviceByHost(h, 8090, "service-store")
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
|
||||
inserted = append(inserted, seededExtraDevice{host: h, conn: conn})
|
||||
|
||||
mu.Unlock()
|
||||
}(host)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for _, entry := range app.DeviceSnapshot() {
|
||||
if _, ok := desired[entry.ID]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if app.removeDeviceIfMatch(entry.ID, entry.Device) {
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
return inserted, removed, desired, nil
|
||||
}
|
||||
|
||||
// SeedExtraDevicesUntilReady retries only the hosts returned by
|
||||
// ExtraDeviceHosts until all of them have been registered or ctx expires. It
|
||||
// does not run mDNS or UPnP discovery. This gives embedded deployments a
|
||||
// bounded way to recover when their persisted speakers are not yet routable
|
||||
// while the service is starting.
|
||||
func (app *WebApp) SeedExtraDevicesUntilReady(ctx context.Context, retryInterval time.Duration) {
|
||||
retryUntilReady(ctx, retryInterval, func() bool {
|
||||
inserted, removed, desired, err := app.seedExtraDevices()
|
||||
if err != nil {
|
||||
log.Printf("SeedExtraDevicesUntilReady: failed to read extra device hosts, will retry: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(inserted) > 0 || removed > 0 {
|
||||
app.BroadcastDeviceList()
|
||||
}
|
||||
|
||||
return app.extraDeviceHostsPresent(desired)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *WebApp) extraDeviceHosts() ([]string, error) {
|
||||
if app.ExtraDeviceHosts == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawHosts, err := app.ExtraDeviceHosts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hosts := make([]string, 0, len(rawHosts))
|
||||
seen := make(map[string]struct{}, len(rawHosts))
|
||||
|
||||
for _, host := range rawHosts {
|
||||
if host == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := seen[host]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
seen[host] = struct{}{}
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func (app *WebApp) extraDeviceHostSet() (map[string]struct{}, error) {
|
||||
hosts, err := app.extraDeviceHosts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
desired := make(map[string]struct{}, len(hosts))
|
||||
|
||||
for _, host := range hosts {
|
||||
desired[host] = struct{}{}
|
||||
}
|
||||
|
||||
return desired, nil
|
||||
}
|
||||
|
||||
func (app *WebApp) extraDeviceHostsPresent(desired map[string]struct{}) bool {
|
||||
for host := range desired {
|
||||
if _, ok := app.GetDevice(host); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func retryUntilReady(ctx context.Context, retryInterval time.Duration, attempt func() bool) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if attempt() {
|
||||
return
|
||||
}
|
||||
|
||||
timer := time.NewTimer(retryInterval)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverDevices refreshes the device registry. When TriggerDiscovery is set
|
||||
@@ -164,6 +354,21 @@ func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discov
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
app.AddDeviceByHost(device.Host, device.Port, "discovered")
|
||||
app.AddDeviceByHost(device.Host, device.Port, classifySource(device.DiscoveryMethod))
|
||||
}
|
||||
}
|
||||
|
||||
// classifySource labels a discovered device "manual" if it came from (at
|
||||
// least in part) a configured host, "discovered" otherwise. discoveryMethod
|
||||
// can be a "+"-joined composite (e.g. "Configuration+mDNS/Bonjour") when
|
||||
// mergeDeviceData combines a configured host with the same device found via
|
||||
// mDNS/UPnP in the same sweep -- match by substring, not exact equality, so
|
||||
// a manually configured host that's also independently discoverable still
|
||||
// gets labeled "manual".
|
||||
func classifySource(discoveryMethod string) string {
|
||||
if strings.Contains(discoveryMethod, "Configuration") {
|
||||
return "manual"
|
||||
}
|
||||
|
||||
return "discovered"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
func TestDiscoverDevicesRetriesConfiguredHosts(t *testing.T) {
|
||||
var available atomic.Bool
|
||||
var infoRequests atomic.Int32
|
||||
|
||||
// NewTestServer (Go 1.27) registers its own t.Cleanup(Close) instead of
|
||||
// needing a manual defer, and fails the test on a handler panic. It
|
||||
// defaults to an in-memory transport reachable only via Server.Client(),
|
||||
// but our production client.NewClient dials a real address, so Start()
|
||||
// (rather than Client()) is used here to get a real loopback listener,
|
||||
// same as the old NewServer -- see https://pkg.go.dev/net/http/httptest#NewTestServer.
|
||||
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
infoRequests.Add(1)
|
||||
if !available.Load() {
|
||||
http.Error(w, "offline", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Configured speaker</name><type>SoundTouch 10</type></info>`))
|
||||
}))
|
||||
server.Start()
|
||||
|
||||
t.Setenv("UPNP_ENABLED", "false")
|
||||
t.Setenv("MDNS_ENABLED", "false")
|
||||
t.Setenv("PREFERRED_DEVICES", "")
|
||||
|
||||
configuredHost := strings.TrimPrefix(server.URL, "http://")
|
||||
discoveryService := NewDiscoveryService("", configuredHost)
|
||||
app := NewWebApp()
|
||||
|
||||
app.DiscoverDevices(context.Background(), discoveryService)
|
||||
if got := app.DeviceCount(); got != 0 {
|
||||
t.Fatalf("device count after offline probe = %d, want 0", got)
|
||||
}
|
||||
|
||||
available.Store(true)
|
||||
app.DiscoverDevices(context.Background(), discoveryService)
|
||||
if got := app.DeviceCount(); got != 1 {
|
||||
t.Fatalf("device count after retry = %d, want 1", got)
|
||||
}
|
||||
if got := infoRequests.Load(); got != 2 {
|
||||
t.Fatalf("/info request count = %d, want 2", got)
|
||||
}
|
||||
|
||||
if !app.RemoveDevice(configuredHost) {
|
||||
t.Fatal("configured device was not registered under its host")
|
||||
}
|
||||
|
||||
// AddDeviceByHost spawns a one-shot status-update goroutine and a 30s-
|
||||
// ticker poll loop on successful registration. RemoveDevice signals the
|
||||
// ticker loop to exit via conn.Done() but doesn't wait for it to actually
|
||||
// observe the close, and the one-shot goroutine has no cancellation at
|
||||
// all. Give them a moment to finish before the deferred server.Close()
|
||||
// runs, so a still-in-flight request against the closing httptest server
|
||||
// doesn't produce log noise or -race flakiness.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// TestClassifySource covers the case the test above can't reach without a
|
||||
// real mDNS/UPnP sweep: mergeDeviceData joins discovery methods with "+"
|
||||
// when a configured host is also found via mDNS/UPnP in the same pass (see
|
||||
// pkg/discovery/unified.go), so DiscoveryMethod is not always exactly
|
||||
// "Configuration" for a manually configured device.
|
||||
func TestClassifySource(t *testing.T) {
|
||||
tests := []struct {
|
||||
discoveryMethod string
|
||||
want string
|
||||
}{
|
||||
{"Configuration", "manual"},
|
||||
{"Configuration+mDNS/Bonjour", "manual"},
|
||||
{"mDNS/Bonjour+Configuration", "manual"},
|
||||
{"mDNS/Bonjour", "discovered"},
|
||||
{"SSDP/UPnP", "discovered"},
|
||||
{"", "discovered"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := classifySource(tt.discoveryMethod); got != tt.want {
|
||||
t.Errorf("classifySource(%q) = %q, want %q", tt.discoveryMethod, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryUntilReadyStopsAfterSuccess(t *testing.T) {
|
||||
attempts := 0
|
||||
retryUntilReady(context.Background(), time.Millisecond, func() bool {
|
||||
attempts++
|
||||
return attempts == 3
|
||||
})
|
||||
|
||||
if attempts != 3 {
|
||||
t.Fatalf("attempt count = %d, want 3", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryUntilReadyStopsAfterContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
attempts := 0
|
||||
|
||||
retryUntilReady(ctx, time.Hour, func() bool {
|
||||
attempts++
|
||||
cancel()
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if attempts != 1 {
|
||||
t.Fatalf("attempt count = %d, want 1", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraDeviceHostsPresent(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
app.ExtraDeviceHosts = func() ([]string, error) { return []string{"known", "", "known", "missing"}, nil }
|
||||
app.AddDevice("known", &webtypes.DeviceConnection{})
|
||||
desired, err := app.extraDeviceHostSet()
|
||||
if err != nil {
|
||||
t.Fatalf("extraDeviceHostSet() error = %v", err)
|
||||
}
|
||||
|
||||
if len(desired) != 2 {
|
||||
t.Fatalf("desired host count = %d, want 2", len(desired))
|
||||
}
|
||||
if app.extraDeviceHostsPresent(desired) {
|
||||
t.Fatal("extraDeviceHostsPresent = true with a missing host")
|
||||
}
|
||||
|
||||
app.AddDevice("missing", &webtypes.DeviceConnection{})
|
||||
if !app.extraDeviceHostsPresent(desired) {
|
||||
t.Fatal("extraDeviceHostsPresent = false with all hosts registered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraDeviceHostSetPropagatesHookError(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
wantErr := errors.New("datastore glitch")
|
||||
app.ExtraDeviceHosts = func() ([]string, error) { return nil, wantErr }
|
||||
|
||||
if _, err := app.extraDeviceHostSet(); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("extraDeviceHostSet() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedExtraDevicesSkipsKnownHosts(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
app.ExtraDeviceHosts = func() ([]string, error) { return []string{"known"}, nil }
|
||||
lastSeen := time.Unix(123, 0)
|
||||
conn := &webtypes.DeviceConnection{LastSeen: lastSeen}
|
||||
app.AddDevice("known", conn)
|
||||
|
||||
app.SeedExtraDevices()
|
||||
|
||||
if !conn.LastSeen.Equal(lastSeen) {
|
||||
t.Fatalf("known host LastSeen changed from %s to %s", lastSeen, conn.LastSeen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedExtraDevicesUntilReadyRetriesOnHookError covers the code-review
|
||||
// finding that a hook error (e.g. a transient datastore read failure) must
|
||||
// not be treated as "zero hosts persisted", which would make the readiness
|
||||
// check trivially pass and end the retry window immediately.
|
||||
func TestSeedExtraDevicesUntilReadyRetriesOnHookError(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
|
||||
app := NewWebApp()
|
||||
app.ExtraDeviceHosts = func() ([]string, error) {
|
||||
n := calls.Add(1)
|
||||
if n < 3 {
|
||||
return nil, errors.New("datastore glitch")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
app.SeedExtraDevicesUntilReady(ctx, time.Millisecond)
|
||||
|
||||
if got := calls.Load(); got < 3 {
|
||||
t.Fatalf("hook call count = %d, want at least 3 (kept retrying past the errors)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedExtraDevicesPrunesHostRemovedAfterEarlierAttempt covers the
|
||||
// code-review finding that pruning must consider the whole registry, not
|
||||
// only hosts inserted during the current call: a host registered by an
|
||||
// earlier seed call that later falls out of ExtraDeviceHosts must still be
|
||||
// pruned by a later call, and this must hold for the plain SeedExtraDevices
|
||||
// path too, not just the bounded retry loop.
|
||||
func TestSeedExtraDevicesPrunesHostRemovedAfterEarlierAttempt(t *testing.T) {
|
||||
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Stale speaker</name><type>SoundTouch 10</type></info>`))
|
||||
}))
|
||||
server.Start()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
var stillDesired atomic.Bool
|
||||
stillDesired.Store(true)
|
||||
|
||||
app := NewWebApp()
|
||||
app.ExtraDeviceHosts = func() ([]string, error) {
|
||||
if stillDesired.Load() {
|
||||
return []string{host}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// First call registers the host.
|
||||
app.SeedExtraDevices()
|
||||
if _, ok := app.GetDevice(host); !ok {
|
||||
t.Fatalf("host %s was not registered on the first seed call", host)
|
||||
}
|
||||
|
||||
// Simulate the device being removed from the datastore in between calls.
|
||||
stillDesired.Store(false)
|
||||
|
||||
// A later plain SeedExtraDevices call (as triggered by
|
||||
// SetDevicesChangedHook or the manual /api/control/discover route) must
|
||||
// still prune it, not just the bounded retry loop.
|
||||
app.SeedExtraDevices()
|
||||
if _, ok := app.GetDevice(host); ok {
|
||||
t.Fatal("stale host was not pruned by a later SeedExtraDevices call")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedExtraDevicesSerializesConcurrentRuns covers the code-review finding
|
||||
// that the bounded startup retry loop and a devices-changed-hook-triggered
|
||||
// SeedExtraDevices call must not issue concurrent probes to the same
|
||||
// still-offline host.
|
||||
func TestSeedExtraDevicesSerializesConcurrentRuns(t *testing.T) {
|
||||
var infoRequests atomic.Int32
|
||||
release := make(chan struct{})
|
||||
|
||||
// Only /info blocks and counts. A successful registration spawns its own
|
||||
// background status-update request (see the comment below), which must
|
||||
// not be mistaken for a second concurrent seed probe.
|
||||
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/info" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
infoRequests.Add(1)
|
||||
<-release // block until the test lets the handler respond
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Slow speaker</name><type>SoundTouch 10</type></info>`))
|
||||
}))
|
||||
server.Start()
|
||||
|
||||
host := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
app := NewWebApp()
|
||||
app.ExtraDeviceHosts = func() ([]string, error) { return []string{host}, nil }
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { app.SeedExtraDevices(); done <- struct{}{} }()
|
||||
go func() { app.SeedExtraDevices(); done <- struct{}{} }()
|
||||
|
||||
// Give both goroutines a moment to reach the handler if they were going
|
||||
// to run concurrently, then let the handler(s) respond.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
close(release)
|
||||
|
||||
<-done
|
||||
<-done
|
||||
|
||||
if got := infoRequests.Load(); got != 1 {
|
||||
t.Fatalf("/info request count = %d, want 1 (concurrent seeds were not serialized)", got)
|
||||
}
|
||||
|
||||
// AddDeviceByHost spawns a one-shot status-update goroutine and a 30s-
|
||||
// ticker poll loop on successful registration (see
|
||||
// TestDiscoverDevicesRetriesConfiguredHosts). Give them a moment to
|
||||
// finish before the deferred server.Close() runs, so a still-in-flight
|
||||
// request against the closing httptest server doesn't produce log noise
|
||||
// or -race flakiness.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestRemoveDeviceIfMatchKeepsReplacement(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
original := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{})
|
||||
replacement := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{})
|
||||
app.AddDevice("speaker", original)
|
||||
app.RemoveDevice("speaker")
|
||||
app.AddDevice("speaker", replacement)
|
||||
|
||||
if app.removeDeviceIfMatch("speaker", original) {
|
||||
t.Fatal("removeDeviceIfMatch removed a replacement connection")
|
||||
}
|
||||
|
||||
got, ok := app.GetDevice("speaker")
|
||||
if !ok || got != replacement {
|
||||
t.Fatal("replacement connection was not preserved")
|
||||
}
|
||||
|
||||
app.RemoveDevice("speaker")
|
||||
}
|
||||
@@ -62,7 +62,12 @@ type WebApp struct {
|
||||
// soundtouch-service points it at the service datastore's known devices so
|
||||
// the UI shows manually-added speakers even when network discovery is
|
||||
// disabled. Standalone soundtouch-player leaves it nil.
|
||||
ExtraDeviceHosts func() []string
|
||||
//
|
||||
// A non-nil error means the underlying read failed (e.g. a datastore
|
||||
// glitch), which callers must NOT treat the same as "zero hosts
|
||||
// persisted" -- doing so would make a transient read failure look like
|
||||
// every persisted device is already registered.
|
||||
ExtraDeviceHosts func() ([]string, error)
|
||||
|
||||
// TriggerDiscovery, when set, runs an external discovery sweep instead of
|
||||
// this app's own mDNS/UPnP. The embedded build wires it to the host
|
||||
@@ -78,6 +83,11 @@ type WebApp struct {
|
||||
// removal only prunes the in-memory registry).
|
||||
RemoveDeviceHook func(deviceID string) error
|
||||
|
||||
// seedMu serializes seedExtraDevices runs so the bounded startup retry
|
||||
// loop (SeedExtraDevicesUntilReady) and a devices-changed-hook-triggered
|
||||
// SeedExtraDevices never probe the same still-offline host concurrently.
|
||||
seedMu sync.Mutex
|
||||
|
||||
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
|
||||
}
|
||||
|
||||
@@ -104,11 +114,12 @@ func (app *WebApp) proxyServiceURL() string {
|
||||
return app.ServiceURL
|
||||
}
|
||||
|
||||
// DeviceEntry pairs a device id with its connection. Used by
|
||||
// DeviceSnapshot so callers can iterate without holding the lock.
|
||||
// DeviceEntry pairs a device id with its connection and the LastSeen value
|
||||
// captured by DeviceSnapshot under the registry lock.
|
||||
type DeviceEntry struct {
|
||||
ID string
|
||||
Device *webtypes.DeviceConnection
|
||||
ID string
|
||||
Device *webtypes.DeviceConnection
|
||||
LastSeen time.Time
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
@@ -132,9 +143,9 @@ func (app *WebApp) GetDevice(id string) (*webtypes.DeviceConnection, bool) {
|
||||
return device, ok
|
||||
}
|
||||
|
||||
// DeviceSnapshot returns a list of (id, *DeviceConnection) pairs taken
|
||||
// under a single read lock. Callers can iterate the result without
|
||||
// holding any registry lock. Devices added or removed after the call
|
||||
// DeviceSnapshot returns device entries taken under a single read lock.
|
||||
// Callers can iterate the result without holding any registry lock.
|
||||
// Devices added or removed after the call
|
||||
// are not reflected. A pointer captured here stays valid even if the
|
||||
// device is later removed (RemoveDevice only detaches it from the map
|
||||
// and stops its goroutines), so iterating a stale snapshot is safe.
|
||||
@@ -144,7 +155,11 @@ func (app *WebApp) DeviceSnapshot() []DeviceEntry {
|
||||
|
||||
out := make([]DeviceEntry, 0, len(app.devices))
|
||||
for id, device := range app.devices {
|
||||
out = append(out, DeviceEntry{ID: id, Device: device})
|
||||
out = append(out, DeviceEntry{
|
||||
ID: id,
|
||||
Device: device,
|
||||
LastSeen: device.LastSeen,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
@@ -214,25 +229,36 @@ func (app *WebApp) RemoveDevice(id string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// removeDeviceIfMatch removes id only when it still points at expected. It is
|
||||
// used when an asynchronous probe must not delete a newer replacement that was
|
||||
// registered under the same host.
|
||||
func (app *WebApp) removeDeviceIfMatch(id string, expected *webtypes.DeviceConnection) bool {
|
||||
app.devicesMu.Lock()
|
||||
|
||||
current, ok := app.devices[id]
|
||||
if ok && current == expected {
|
||||
delete(app.devices, id)
|
||||
} else {
|
||||
ok = false
|
||||
}
|
||||
|
||||
app.devicesMu.Unlock()
|
||||
|
||||
if ok {
|
||||
expected.Close()
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
snapshot := app.DeviceSnapshot()
|
||||
devices := make(map[string]interface{}, len(snapshot))
|
||||
|
||||
for _, entry := range snapshot {
|
||||
devices[entry.ID] = map[string]interface{}{
|
||||
"info": entry.Device.DeviceInfo,
|
||||
"status": entry.Device.Status(),
|
||||
"lastSeen": entry.Device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
Data: app.deviceViewSnapshot(),
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
@@ -702,20 +728,9 @@ func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
snapshot := app.DeviceSnapshot()
|
||||
devices := make(map[string]interface{}, len(snapshot))
|
||||
|
||||
for _, entry := range snapshot {
|
||||
devices[entry.ID] = map[string]interface{}{
|
||||
"info": entry.Device.DeviceInfo,
|
||||
"status": entry.Device.Status(),
|
||||
"lastSeen": entry.Device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
Data: app.deviceViewSnapshot(),
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
@@ -1084,6 +1099,54 @@ func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
|
||||
"Left zone")
|
||||
}
|
||||
|
||||
// HandleGetZoneCandidates returns every registered physical device, for the
|
||||
// "add to zone" picker. Unlike HandleAPIDevices, this deliberately bypasses
|
||||
// the stereo-pair projection (deviceViewSnapshot): Zone and Group are
|
||||
// separate, unrelated groupings, and a device that's currently hidden as a
|
||||
// stereo-pair member (see device_projection.go) must still be an
|
||||
// independently addressable zone target, exactly as the backend
|
||||
// HandleZoneAdd/HandleZoneRemove already treat it (both look devices up via
|
||||
// the raw registry, unaffected by projection).
|
||||
//
|
||||
// Deliberately does not exclude the {id} device itself: which candidates to
|
||||
// exclude (the page's own device, current zone members, ...) is a caller
|
||||
// concern, not an inherent property of "what devices exist" -- excluding it
|
||||
// here would make this endpoint silently unusable for any future caller
|
||||
// that isn't Zone.js's current "add to my own zone" flow.
|
||||
func (app *WebApp) HandleGetZoneCandidates(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
if _, exists := app.GetDevice(deviceID); !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
type zoneCandidate struct {
|
||||
Info *models.DeviceInfo `json:"info,omitempty"`
|
||||
}
|
||||
|
||||
candidates := make(map[string]zoneCandidate)
|
||||
|
||||
for _, entry := range app.DeviceSnapshot() {
|
||||
if entry.Device == nil || entry.Device.DeviceInfo == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates[entry.ID] = zoneCandidate{Info: entry.Device.DeviceInfo}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: candidates,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceRecents returns recently played items for a device.
|
||||
func (app *WebApp) HandleDeviceRecents(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
@@ -893,3 +893,78 @@ func TestHandleZoneLeave_UsesRemoveZoneSlave(t *testing.T) {
|
||||
t.Errorf("removeZoneSlave body should name the master, got: %s", masterBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleGetZoneCandidates_IncludesHiddenPairMemberAndSelf guards two
|
||||
// things the stereo-pair projection (device_projection.go) must not affect:
|
||||
// Zone and Group are separate, unrelated groupings, so (a) a stereo pair's
|
||||
// hidden non-master member -- absent from the collapsed "devices" list --
|
||||
// must still be a valid zone-add candidate, matching how HandleZoneAdd
|
||||
// already treats it (raw registry lookup, unaffected by projection); and
|
||||
// (b) the endpoint itself does not exclude the requesting {id} device,
|
||||
// since deciding what to exclude (this page's own device, current zone
|
||||
// members, ...) is the caller's concern -- Zone.js already does this via
|
||||
// the existing zoneIps set, which includes the master's own IP even for a
|
||||
// standalone zone (see models.ZoneInfo.IsStandalone).
|
||||
func TestHandleGetZoneCandidates_IncludesHiddenPairMemberAndSelf(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
group := testStereoGroup()
|
||||
master := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "left-id", Name: "Living Room", IPAddress: "192.0.2.10"})
|
||||
master.SetStatus(&webtypes.DeviceStatus{IsConnected: true, Group: group})
|
||||
app.AddDevice("192.0.2.10", master)
|
||||
|
||||
hiddenMember := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "right-id", Name: "Living Room", IPAddress: "192.0.2.11"})
|
||||
hiddenMember.SetStatus(&webtypes.DeviceStatus{IsConnected: true, Group: group})
|
||||
app.AddDevice("192.0.2.11", hiddenMember)
|
||||
|
||||
standalone := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "kitchen-id", Name: "Kitchen", IPAddress: "192.0.2.12"})
|
||||
standalone.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
|
||||
app.AddDevice("192.0.2.12", standalone)
|
||||
|
||||
// Sanity check: the hidden member really is absent from the projected
|
||||
// device list this test is guarding against leaking into.
|
||||
projected := app.deviceViewSnapshot()
|
||||
if _, visible := projected["192.0.2.11"]; visible {
|
||||
t.Fatal("test setup: expected 192.0.2.11 to be hidden by the stereo-pair projection")
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/devices/192.0.2.10/zone/candidates", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "192.0.2.10"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleGetZoneCandidates(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
data, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected data to be a map, got %T", response.Data)
|
||||
}
|
||||
|
||||
for _, ip := range []string{"192.0.2.10", "192.0.2.11", "192.0.2.12"} {
|
||||
if _, ok := data[ip]; !ok {
|
||||
t.Errorf("expected %s in zone candidates, got: %+v", ip, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetZoneCandidates_UnknownDeviceNotFound(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/control/devices/192.0.2.99/zone/candidates", nil)
|
||||
req = withChiParams(req, map[string]string{"id": "192.0.2.99"})
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
app.HandleGetZoneCandidates(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for an unknown device, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDis
|
||||
|
||||
r.Route("/zone", func(r chi.Router) {
|
||||
r.Get("/", app.HandleGetZone)
|
||||
r.Get("/candidates", app.HandleGetZoneCandidates)
|
||||
r.Post("/add/{slaveId}", app.HandleZoneAdd)
|
||||
r.Post("/remove/{slaveId}", app.HandleZoneRemove)
|
||||
r.Post("/dissolve", app.HandleZoneDissolve)
|
||||
|
||||
@@ -399,6 +399,8 @@ img { display: block; max-width: 100%; }
|
||||
}
|
||||
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; }
|
||||
.device-ip { color: var(--text); font-family: monospace; font-weight: 500; }
|
||||
.stereo-pair-state { color: var(--accent); font-weight: 600; }
|
||||
.stereo-pair-state.degraded { color: var(--offline); }
|
||||
|
||||
.device-indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
|
||||
@@ -5,6 +5,31 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AfterTouch</title>
|
||||
<meta name="description" content="AfterTouch — A replacement for Bose SoundTouch cloud services. Control your speakers after the cloud shutdown." />
|
||||
<link rel="icon" type="image/svg+xml" href="/app/static/img/favicon.svg" />
|
||||
<link rel="alternate icon" href="/app/static/img/favicon.ico" />
|
||||
<link rel="stylesheet" href="/app/static/css/app.css" />
|
||||
<!--
|
||||
Safari on iPadOS 15 supports native ES modules but not import maps
|
||||
(added in Safari 16.4), which left the page blank there (#649).
|
||||
es-module-shims polyfills import map support, but it's an ~80KB
|
||||
uncompressed download; loading it unconditionally would charge every
|
||||
browser that cost on every page view, including the vast majority
|
||||
that already support import maps natively. Feature-detect instead,
|
||||
so only a browser that actually lacks support ever fetches it.
|
||||
Inserted via the DOM rather than document.write (deprecated, and
|
||||
subject to browser interventions that can silently drop it), with
|
||||
async explicitly set to false so it still executes before the
|
||||
deferred `type="module"` script below runs, without blocking the
|
||||
parser for the vast majority that never reach this branch.
|
||||
-->
|
||||
<script>
|
||||
if (!(window.HTMLScriptElement && HTMLScriptElement.supports && HTMLScriptElement.supports('importmap'))) {
|
||||
var esModuleShimsScript = document.createElement('script');
|
||||
esModuleShimsScript.src = '/app/static/lib/es-module-shims.js';
|
||||
esModuleShimsScript.async = false;
|
||||
document.head.appendChild(esModuleShimsScript);
|
||||
}
|
||||
</script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
@@ -14,9 +39,6 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="/app/static/img/favicon.svg" />
|
||||
<link rel="alternate icon" href="/app/static/img/favicon.ico" />
|
||||
<link rel="stylesheet" href="/app/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -20,6 +20,7 @@ export const api = {
|
||||
power: (id) => req(`/api/control/devices/${id}/power`, { method: 'POST' }),
|
||||
recents: (id) => req(`/api/control/devices/${id}/recents`),
|
||||
zone: (id) => req(`/api/control/devices/${id}/zone`),
|
||||
zoneCandidates: (id) => req(`/api/control/devices/${id}/zone/candidates`),
|
||||
zoneAdd: (masterId, slaveId) => req(`/api/control/devices/${masterId}/zone/add/${slaveId}`, { method: 'POST' }),
|
||||
zoneRemove: (masterId, slaveId) => req(`/api/control/devices/${masterId}/zone/remove/${slaveId}`, { method: 'POST' }),
|
||||
zoneDissolve: (id) => req(`/api/control/devices/${id}/zone/dissolve`, { method: 'POST' }),
|
||||
|
||||
@@ -102,7 +102,6 @@ function App() {
|
||||
if (msg.type === 'devices') {
|
||||
setDevices(msg.data || {});
|
||||
} else if (msg.type === 'discovery_status') {
|
||||
console.log('[DEBUG_LOG] discovery_status:', msg.data);
|
||||
if (msg.data?.isDiscovering !== undefined) {
|
||||
setIsDiscovering(msg.data.isDiscovering);
|
||||
} else if (msg.data?.status === 'starting') {
|
||||
@@ -116,7 +115,11 @@ function App() {
|
||||
}
|
||||
} else if (msg.type === 'status_update' && msg.deviceId) {
|
||||
setDevices(prev => {
|
||||
if (!prev[msg.deviceId]) return prev;
|
||||
// Object.prototype.hasOwnProperty, not a plain prev[msg.deviceId]
|
||||
// truthy check: a deviceId of "__proto__" or "constructor" would
|
||||
// otherwise resolve through the prototype chain to a truthy value
|
||||
// and pass the check despite not being a real, known device.
|
||||
if (!Object.prototype.hasOwnProperty.call(prev, msg.deviceId)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
|
||||
@@ -135,6 +138,13 @@ function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId && !devices[selectedId]) {
|
||||
setSelectedId(null);
|
||||
if (page === 'device') setPage('devices');
|
||||
}
|
||||
}, [devices, selectedId, page]);
|
||||
|
||||
function showToast(msg) {
|
||||
setToast(null);
|
||||
setTimeout(() => setToast(msg), 10);
|
||||
|
||||
@@ -23,6 +23,7 @@ function sortEntries(entries, mode) {
|
||||
|
||||
function DeviceCard({ id, device, onSelect, onRemove }) {
|
||||
const { info, status } = device;
|
||||
const stereoPair = device.stereoPair;
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const isStandby = !np || np.Source === 'STANDBY';
|
||||
@@ -33,14 +34,19 @@ function DeviceCard({ id, device, onSelect, onRemove }) {
|
||||
<span class="device-name">${info?.name || id}</span>
|
||||
<span class="device-header-right">
|
||||
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
|
||||
<button class="device-remove" title="Remove this device"
|
||||
${!stereoPair ? html`<button class="device-remove" title="Remove this device"
|
||||
aria-label="Remove this device"
|
||||
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}>✕</button>
|
||||
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}>✕</button>` : null}
|
||||
</span>
|
||||
</div>
|
||||
<div class="device-type">
|
||||
${info?.type || ''}
|
||||
${info?.ip_address ? html`<span class="device-ip">(${info.ip_address})</span>` : null}
|
||||
${stereoPair ? html`
|
||||
<span class="stereo-pair-state ${stereoPair.degraded ? 'degraded' : ''}">
|
||||
Stereo pair ${stereoPair.availableMemberCount}/${stereoPair.memberCount}
|
||||
</span>
|
||||
` : null}
|
||||
</div>
|
||||
${!isStandby ? html`
|
||||
<div class="now-playing-mini">
|
||||
|
||||
@@ -23,10 +23,27 @@ export function Library({ devices }) {
|
||||
// invalidate the current selection.
|
||||
useEffect(() => {
|
||||
const entries = Object.entries(devices);
|
||||
if (!deviceId && entries.length > 0) {
|
||||
setDeviceId(entries[0][0]);
|
||||
if (deviceId && devices[deviceId]) return;
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (deviceId) setDeviceId(null);
|
||||
return;
|
||||
}
|
||||
}, [devices]);
|
||||
|
||||
if (deviceId) {
|
||||
// The selected device vanished from the list -- if that's because
|
||||
// it just became a hidden stereo-pair member (see
|
||||
// device_projection.go), follow it to its pair's master instead
|
||||
// of silently jumping to an unrelated device.
|
||||
const master = entries.find(([, d]) => d.stereoPair?.members?.some(m => m.ipAddress === deviceId));
|
||||
if (master) {
|
||||
setDeviceId(master[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDeviceId(entries[0][0]);
|
||||
}, [devices, deviceId]);
|
||||
|
||||
// Reload registered servers whenever deviceId changes.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,13 +7,17 @@ const html = htm.bind(h);
|
||||
|
||||
export function Zone({ deviceId, devices }) {
|
||||
const [zone, setZone] = useState(null);
|
||||
const [candidates, setCandidates] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
api.zone(deviceId).then(resp => {
|
||||
if (resp.success) setZone(resp.data);
|
||||
}).finally(() => setLoading(false));
|
||||
Promise.all([api.zone(deviceId), api.zoneCandidates(deviceId)])
|
||||
.then(([zoneResp, candidatesResp]) => {
|
||||
if (zoneResp.success) setZone(zoneResp.data);
|
||||
if (candidatesResp.success) setCandidates(candidatesResp.data || {});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [deviceId]);
|
||||
@@ -48,11 +52,15 @@ export function Zone({ deviceId, devices }) {
|
||||
|
||||
if (!zone) return null;
|
||||
|
||||
// Devices not already in the zone are available to add
|
||||
// Devices not already in the zone are available to add. Sourced from a
|
||||
// dedicated candidates endpoint rather than the `devices` prop: Zone and
|
||||
// Group are separate, unrelated groupings, so a stereo pair's hidden
|
||||
// member -- absent from the collapsed device list -- is still a valid,
|
||||
// independent zone-add target.
|
||||
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
|
||||
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
|
||||
const available = Object.entries(candidates).filter(([ip]) => !zoneIps.has(ip));
|
||||
|
||||
const deviceName = (ip) => devices[ip]?.info?.name || ip;
|
||||
const deviceName = (ip) => devices[ip]?.info?.name || candidates[ip]?.info?.name || ip;
|
||||
|
||||
return html`
|
||||
<div class="zone-section">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Vendored Frontend Dependencies
|
||||
|
||||
The JavaScript files in the parent directory are copied unmodified from the
|
||||
exact npm packages pinned in the repository's `package-lock.json`.
|
||||
|
||||
This directory travels with the embedded web UI and contains each package's
|
||||
upstream license. Its copy of `package-lock.json` records the exact versions,
|
||||
npm tarball URLs, and integrity hashes used to generate the assets:
|
||||
|
||||
- `preact.module.js` and `preact-hooks.module.js`: `preact`
|
||||
- `htm.module.js`: `htm`
|
||||
- `es-module-shims.js`: `es-module-shims`
|
||||
|
||||
Run `make update-static-deps` to regenerate the assets, licenses, and package
|
||||
metadata together.
|
||||
@@ -0,0 +1,10 @@
|
||||
MIT License
|
||||
-----------
|
||||
|
||||
Copyright (C) 2018-2021 Guy Bedford
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2018 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@gesellix/bose-soundtouch",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gesellix/bose-soundtouch",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-module-shims": "2.8.4",
|
||||
"htm": "3.1.1",
|
||||
"preact": "10.29.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-shims": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.4.tgz",
|
||||
"integrity": "sha512-ea5srn5L89PWVad6Qle6r2kg+HvvLiL/GHqgNx06eFrkERHkrTFWaqo1w8Sd+XI3Xr0iAG5x3ZQ7mQ/hnQzNpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/htm": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
|
||||
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.8",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact-render-to-string": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"preact-render-to-string": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present Jason Miller
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIndexPolyfillsImportMapsForOlderBrowsers guards the Safari-on-iPadOS-15
|
||||
// fix (#649): that browser supports native ES modules but not import maps
|
||||
// (added in Safari 16.4), so the page loaded blank there. es-module-shims
|
||||
// polyfills import map resolution for such browsers, but is an ~80KB
|
||||
// uncompressed download, so it must be feature-detected and only injected
|
||||
// for a browser that actually lacks HTMLScriptElement.supports('importmap'),
|
||||
// before the import map is parsed -- not loaded unconditionally for every
|
||||
// browser.
|
||||
func TestIndexPolyfillsImportMapsForOlderBrowsers(t *testing.T) {
|
||||
index, err := fs.ReadFile(StaticFS, "static/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read index: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(index, []byte(`HTMLScriptElement.supports('importmap')`)) {
|
||||
t.Fatal("index.html does not feature-detect import map support before loading es-module-shims")
|
||||
}
|
||||
|
||||
if bytes.Contains(index, []byte(`document.write(`)) {
|
||||
t.Fatal("index.html must not call document.write() (deprecated, subject to browser interventions); use DOM insertion instead")
|
||||
}
|
||||
|
||||
if !bytes.Contains(index, []byte(`.async = false`)) {
|
||||
t.Fatal("the dynamically-inserted es-module-shims script must set async = false to preserve execution order")
|
||||
}
|
||||
|
||||
shimIdx := bytes.Index(index, []byte(`esModuleShimsScript.src = '/app/static/lib/es-module-shims.js';`))
|
||||
if shimIdx == -1 {
|
||||
t.Fatal("index.html does not conditionally inject es-module-shims.js")
|
||||
}
|
||||
|
||||
importMapIdx := bytes.Index(index, []byte(`<script type="importmap">`))
|
||||
if importMapIdx == -1 {
|
||||
t.Fatal("index.html does not declare an import map")
|
||||
}
|
||||
|
||||
if shimIdx > importMapIdx {
|
||||
t.Fatal("the es-module-shims feature-detect/injection must come before the import map so it can polyfill browsers without native support")
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(StaticFS, "static/lib/es-module-shims.js"); err != nil {
|
||||
t.Errorf("es-module-shims is not vendored/embedded: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVendoredDependenciesIncludeLicenses ensures that the license and package
|
||||
// metadata copied with the embedded frontend assets are also present in release
|
||||
// binaries. scripts/update-static-deps.sh owns these generated files.
|
||||
func TestVendoredDependenciesIncludeLicenses(t *testing.T) {
|
||||
for _, dependency := range []string{"preact", "htm", "es-module-shims"} {
|
||||
path := "static/lib/LICENSES/" + dependency + "-LICENSE"
|
||||
if _, err := fs.Stat(StaticFS, path); err != nil {
|
||||
t.Errorf("vendored dependency license %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(StaticFS, "static/lib/LICENSES/package-lock.json"); err != nil {
|
||||
t.Errorf("vendored dependency provenance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexImportMapCoversAllVendoredModules guards against the import map
|
||||
// and the vendored files it points at drifting apart.
|
||||
func TestIndexImportMapCoversAllVendoredModules(t *testing.T) {
|
||||
index, err := fs.ReadFile(StaticFS, "static/index.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read index: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
specifier string
|
||||
mappedURL string
|
||||
embeddedPath string
|
||||
}{
|
||||
{"preact", "/app/static/lib/preact.module.js", "static/lib/preact.module.js"},
|
||||
{"preact/hooks", "/app/static/lib/preact-hooks.module.js", "static/lib/preact-hooks.module.js"},
|
||||
{"htm", "/app/static/lib/htm.module.js", "static/lib/htm.module.js"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
if !bytes.Contains(index, []byte(`"`+c.specifier+`": "`+c.mappedURL+`"`)) {
|
||||
t.Errorf("import map does not map %q to %q", c.specifier, c.mappedURL)
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(StaticFS, c.embeddedPath); err != nil {
|
||||
t.Errorf("vendored dependency %q: %v", c.specifier, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreactHooksUsesUnmodifiedPeerImport guards against re-introducing a
|
||||
// sed-patched vendor file: with the import map restored, the vendored
|
||||
// preact/hooks build's peer import of "preact" must stay a bare specifier,
|
||||
// resolved like every other component through the import map (and, for
|
||||
// browsers that need it, through the es-module-shims polyfill) rather than
|
||||
// a hardcoded relative path baked in at vendoring time.
|
||||
func TestPreactHooksUsesUnmodifiedPeerImport(t *testing.T) {
|
||||
hooks, err := fs.ReadFile(StaticFS, "static/lib/preact-hooks.module.js")
|
||||
if err != nil {
|
||||
t.Fatalf("read preact-hooks.module.js: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Contains(hooks, []byte(`from"preact"`)) {
|
||||
t.Error(`preact-hooks.module.js should import the unmodified bare "preact" specifier`)
|
||||
}
|
||||
}
|
||||
@@ -46,23 +46,10 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Send initial device list
|
||||
snapshot := app.DeviceSnapshot()
|
||||
devices := make(map[string]interface{}, len(snapshot))
|
||||
|
||||
for _, entry := range snapshot {
|
||||
devices[entry.ID] = map[string]interface{}{
|
||||
"info": entry.Device.DeviceInfo,
|
||||
"status": entry.Device.Status(),
|
||||
"lastSeen": entry.Device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
Data: app.deviceViewSnapshot(),
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -100,25 +87,39 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for _, entry := range app.DeviceSnapshot() {
|
||||
status := entry.Device.Status()
|
||||
if status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: entry.ID,
|
||||
Data: status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
for _, message := range app.periodicPlayerMessages() {
|
||||
if err := conn.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// periodicPlayerMessages refreshes the projected inventory while retaining
|
||||
// the established per-device status_update stream for API clients.
|
||||
func (app *WebApp) periodicPlayerMessages() []webtypes.WebSocketMessage {
|
||||
snapshot := captureDeviceProjectionEntries(app.DeviceSnapshot())
|
||||
messages := []webtypes.WebSocketMessage{{
|
||||
Type: "devices",
|
||||
Data: projectCapturedDeviceEntries(snapshot),
|
||||
}}
|
||||
|
||||
for _, entry := range snapshot {
|
||||
if entry.Status == nil || !entry.Status.IsConnected {
|
||||
continue
|
||||
}
|
||||
|
||||
messages = append(messages, webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: entry.ID,
|
||||
Data: entry.Status,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -218,6 +219,10 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
})
|
||||
})
|
||||
|
||||
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
|
||||
applyGroupUpdatedEvent(conn, event)
|
||||
})
|
||||
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v (retrying in %s)", sanitizeLog(deviceID), err, backoff)
|
||||
|
||||
@@ -298,6 +303,16 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
|
||||
return
|
||||
}
|
||||
|
||||
// /getGroup must be gated to ST10 models -- see Client.GetGroup's doc
|
||||
// comment (verified against real hardware: a ST20 never replies at all,
|
||||
// hanging until the client's timeout instead of returning quickly).
|
||||
stereoCapable := stereoPairCapable(conn.DeviceInfo)
|
||||
|
||||
var groupGeneration uint64
|
||||
if stereoCapable {
|
||||
groupGeneration = conn.BeginGroupRefresh()
|
||||
}
|
||||
|
||||
// Phase 1: slow network fetches. Local vars only, no shared state
|
||||
// is touched yet. Errors are recorded so the merge below can tell
|
||||
// "field N stayed unchanged" apart from "field N got refreshed".
|
||||
@@ -307,6 +322,15 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
|
||||
sources, sourcesErr := conn.Client.GetSources()
|
||||
bass, bassErr := conn.Client.GetBass()
|
||||
|
||||
var (
|
||||
group *models.Group
|
||||
groupErr error
|
||||
)
|
||||
|
||||
if stereoCapable {
|
||||
group, groupErr = conn.Client.GetGroup()
|
||||
}
|
||||
|
||||
// Phase 2: fast merge. Only fields we successfully fetched
|
||||
// overwrite; everything else keeps the value other goroutines may
|
||||
// have just written.
|
||||
@@ -338,11 +362,24 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one
|
||||
// status from this round. Mirrors prior behaviour.
|
||||
// Mark as connected if we successfully got at least one status
|
||||
// from this round. Mirrors prior behaviour: deliberately does NOT
|
||||
// fold groupErr in here. GetGroup is gated to stereo-capable
|
||||
// models and trivially succeeds even when a device is otherwise
|
||||
// struggling (an empty <group/> is a near-guaranteed reply), so
|
||||
// counting it would let a device report connected while every
|
||||
// substantive status fetch above actually failed this round.
|
||||
s.IsConnected = statusUpdated
|
||||
s.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
if stereoCapable && groupErr == nil {
|
||||
conn.ApplyPolledGroup(groupGeneration, group)
|
||||
}
|
||||
}
|
||||
|
||||
func applyGroupUpdatedEvent(conn *webtypes.DeviceConnection, event *models.GroupUpdatedEvent) {
|
||||
conn.ApplyGroupEvent(&event.Group, time.Now())
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
)
|
||||
|
||||
func TestUpdateDeviceStatusRefreshesGroup(t *testing.T) {
|
||||
server := newStatusTestServer(t, http.StatusOK, `<group id="pair-1">
|
||||
<name>Living Room</name>
|
||||
<masterDeviceId>master-1</masterDeviceId>
|
||||
<roles>
|
||||
<groupRole><deviceId>master-1</deviceId><role>LEFT</role></groupRole>
|
||||
<groupRole><deviceId>member-1</deviceId><role>RIGHT</role></groupRole>
|
||||
</roles>
|
||||
<status>GROUP_OK</status>
|
||||
</group>`)
|
||||
defer server.Close()
|
||||
|
||||
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
|
||||
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
||||
|
||||
status := conn.Status()
|
||||
if status.Group == nil {
|
||||
t.Fatal("Group was not populated by UpdateDeviceStatus")
|
||||
}
|
||||
|
||||
if status.Group.ID != "pair-1" || status.Group.MasterDeviceID != "master-1" {
|
||||
t.Errorf("Group = %+v, want refreshed stereo pair", status.Group)
|
||||
}
|
||||
|
||||
if len(status.Group.Roles.Roles) != 2 {
|
||||
t.Errorf("group roles = %d, want 2", len(status.Group.Roles.Roles))
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("successful status refresh should mark the device connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeviceStatusPreservesGroupOnError(t *testing.T) {
|
||||
server := newStatusTestServer(t, http.StatusInternalServerError, "group unavailable")
|
||||
defer server.Close()
|
||||
|
||||
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
|
||||
existing := &models.Group{ID: "pair-old", Name: "Existing Pair"}
|
||||
conn.SetStatus(&webtypes.DeviceStatus{Group: existing})
|
||||
|
||||
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
||||
|
||||
status := conn.Status()
|
||||
if status.Group != existing {
|
||||
t.Errorf("Group = %+v, want previous group preserved on refresh error", status.Group)
|
||||
}
|
||||
|
||||
if !status.IsConnected {
|
||||
t.Error("other successful status fetches should keep the device connected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDeviceStatusSkipsGroupForNonStereoModel(t *testing.T) {
|
||||
var groupRequested atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getGroup" {
|
||||
groupRequested.Store(true)
|
||||
http.Error(w, "unsupported endpoint", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
responses := map[string]string{
|
||||
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
|
||||
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
|
||||
"/presets": `<presets/>`,
|
||||
"/sources": `<sources/>`,
|
||||
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
|
||||
}
|
||||
body, ok := responses[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, model := range []string{"SoundTouch 20", "SoundTouch 30"} {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: model})
|
||||
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
||||
|
||||
if !conn.Status().IsConnected {
|
||||
t.Fatal("successful ordinary status requests should mark a non-stereo model connected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if groupRequested.Load() {
|
||||
t.Fatal("UpdateDeviceStatus requested /getGroup for a non-stereo model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds covers a stereo-
|
||||
// capable device where every substantive status fetch fails but /getGroup
|
||||
// alone succeeds (a near-guaranteed reply -- even an empty <group/> is a
|
||||
// success, see Client.GetGroup's doc comment). IsConnected must not be set
|
||||
// from GetGroup's success alone, or a device with genuinely stale
|
||||
// NowPlaying/Volume/Presets/Sources/Bass data would be reported connected.
|
||||
func TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/getGroup" {
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
_, _ = w.Write([]byte(`<group/>`))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "device struggling", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
|
||||
NewWebApp().UpdateDeviceStatus("device-1", conn)
|
||||
|
||||
if conn.Status().IsConnected {
|
||||
t.Fatal("IsConnected must stay false when every substantive status fetch failed, even though GetGroup alone succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGroupUpdatedEventReplacesGroup(t *testing.T) {
|
||||
conn := webtypes.NewDeviceConnection(nil, nil)
|
||||
previousActivity := time.Unix(1, 0)
|
||||
conn.SetStatus(&webtypes.DeviceStatus{
|
||||
Group: &models.Group{ID: "pair-old"},
|
||||
Volume: &models.Volume{ActualVolume: 25},
|
||||
IsConnected: true,
|
||||
LastActivity: previousActivity,
|
||||
})
|
||||
|
||||
event := &models.GroupUpdatedEvent{
|
||||
Group: models.Group{ID: "pair-new", Name: "Renamed Pair"},
|
||||
}
|
||||
applyGroupUpdatedEvent(conn, event)
|
||||
|
||||
status := conn.Status()
|
||||
if status.Group != &event.Group || status.Group.ID != "pair-new" {
|
||||
t.Errorf("Group = %+v, want event group", status.Group)
|
||||
}
|
||||
|
||||
if status.Volume == nil || status.Volume.ActualVolume != 25 || !status.IsConnected {
|
||||
t.Errorf("unrelated status fields were not preserved: %+v", status)
|
||||
}
|
||||
|
||||
if !status.LastActivity.After(previousActivity) {
|
||||
t.Errorf("LastActivity = %s, want after %s", status.LastActivity, previousActivity)
|
||||
}
|
||||
|
||||
teardown := &models.GroupUpdatedEvent{Group: models.Group{}}
|
||||
applyGroupUpdatedEvent(conn, teardown)
|
||||
|
||||
if conn.Status().Group != nil {
|
||||
t.Errorf("teardown event did not clear the group: %+v", conn.Status().Group)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeriodicPlayerMessagesPreserveStatusUpdateStream(t *testing.T) {
|
||||
app := NewWebApp()
|
||||
group := testStereoGroup()
|
||||
for _, entry := range []DeviceEntry{
|
||||
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
|
||||
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
|
||||
projectionDevice("192.0.2.12", "standalone-id", "Kitchen", false, nil),
|
||||
} {
|
||||
app.AddDevice(entry.ID, entry.Device)
|
||||
}
|
||||
|
||||
messages := app.periodicPlayerMessages()
|
||||
if len(messages) != 3 {
|
||||
t.Fatalf("periodic messages = %d, want one devices frame and two connected status updates: %+v", len(messages), messages)
|
||||
}
|
||||
|
||||
if messages[0].Type != "devices" {
|
||||
t.Fatalf("first periodic message type = %q, want devices", messages[0].Type)
|
||||
}
|
||||
devices, ok := messages[0].Data.(map[string]deviceView)
|
||||
if !ok || len(devices) != 2 || devices["192.0.2.10"].StereoPair == nil {
|
||||
t.Fatalf("periodic devices frame is not the logical projection: %#v", messages[0].Data)
|
||||
}
|
||||
|
||||
statusUpdates := make(map[string]bool)
|
||||
for _, message := range messages[1:] {
|
||||
if message.Type != "status_update" {
|
||||
t.Fatalf("periodic message type = %q, want status_update", message.Type)
|
||||
}
|
||||
statusUpdates[message.DeviceID] = true
|
||||
}
|
||||
if !statusUpdates["192.0.2.10"] || !statusUpdates["192.0.2.11"] || statusUpdates["192.0.2.12"] {
|
||||
t.Fatalf("unexpected status_update device IDs: %+v", statusUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
func newStatusTestServer(t *testing.T, groupStatus int, groupBody string) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
responses := map[string]string{
|
||||
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
|
||||
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
|
||||
"/presets": `<presets/>`,
|
||||
"/sources": `<sources/>`,
|
||||
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
|
||||
}
|
||||
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Errorf("method for %s = %s, want GET", r.URL.Path, r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
|
||||
if r.URL.Path == "/getGroup" {
|
||||
w.WriteHeader(groupStatus)
|
||||
_, _ = w.Write([]byte(groupBody))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body, ok := responses[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected status endpoint %q", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
package webtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -75,6 +77,7 @@ func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
|
||||
conn.SetStatus(&DeviceStatus{
|
||||
Volume: &models.Volume{ActualVolume: 10},
|
||||
Bass: &models.Bass{ActualBass: 3},
|
||||
Group: &models.Group{ID: "pair-1", Name: "Living Room"},
|
||||
IsConnected: true,
|
||||
})
|
||||
|
||||
@@ -92,11 +95,120 @@ func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
|
||||
t.Errorf("Bass not preserved: %+v", got.Bass)
|
||||
}
|
||||
|
||||
if got.Group == nil || got.Group.ID != "pair-1" {
|
||||
t.Errorf("Group not preserved: %+v", got.Group)
|
||||
}
|
||||
|
||||
if !got.IsConnected {
|
||||
t.Error("IsConnected not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceStatusGroupJSON(t *testing.T) {
|
||||
status := DeviceStatus{
|
||||
Group: &models.Group{
|
||||
ID: "pair-1",
|
||||
Name: "Living Room",
|
||||
MasterDeviceID: "master-1",
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
Group *models.Group `json:"group"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("Unmarshal DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Group == nil || decoded.Group.ID != "pair-1" || decoded.Group.MasterDeviceID != "master-1" {
|
||||
t.Fatalf("group did not round-trip in status JSON: %+v", decoded.Group)
|
||||
}
|
||||
|
||||
emptyPayload, err := json.Marshal(DeviceStatus{})
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal empty DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
var emptyDecoded map[string]json.RawMessage
|
||||
if err := json.Unmarshal(emptyPayload, &emptyDecoded); err != nil {
|
||||
t.Fatalf("Unmarshal empty DeviceStatus: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := emptyDecoded["group"]; ok {
|
||||
t.Errorf("nil group should be omitted, JSON = %s", emptyPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupEventSupersedesInFlightPoll(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
generation := conn.BeginGroupRefresh()
|
||||
|
||||
eventGroup := &models.Group{ID: "new-pair", MasterDeviceID: "master"}
|
||||
if !conn.ApplyGroupEvent(eventGroup, time.Now()) {
|
||||
t.Fatal("new group event should change group state")
|
||||
}
|
||||
|
||||
if conn.ApplyPolledGroup(generation, &models.Group{ID: "stale-pair"}) {
|
||||
t.Fatal("stale poll must not replace a newer group event")
|
||||
}
|
||||
|
||||
if got := conn.Status().Group; got == nil || got.ID != "new-pair" {
|
||||
t.Fatalf("Group = %+v, want newer event state", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyGroupClearsCurrentClaim(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
conn.SetStatus(&DeviceStatus{Group: &models.Group{ID: "pair-1"}})
|
||||
|
||||
if !conn.ApplyGroupEvent(&models.Group{}, time.Now()) {
|
||||
t.Fatal("empty teardown event should change group state")
|
||||
}
|
||||
|
||||
if got := conn.Status().Group; got != nil {
|
||||
t.Fatalf("Group = %+v, want nil after teardown", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyGroupEventIgnoresRoleOrder guards replaceGroup's change-detection
|
||||
// against a spurious "changed" report when the same pair's roles simply
|
||||
// arrive in a different order -- a polled /getGroup response and a pushed
|
||||
// groupUpdated event both populate Roles.Roles straight from XML unmarshal
|
||||
// in wire order, so nothing guarantees they list LEFT/RIGHT the same way
|
||||
// every time for the identical pair.
|
||||
func TestApplyGroupEventIgnoresRoleOrder(t *testing.T) {
|
||||
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
|
||||
|
||||
leftFirst := &models.Group{
|
||||
ID: "pair-1",
|
||||
MasterDeviceID: "master",
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: "master", Role: "LEFT"},
|
||||
{DeviceID: "member", Role: "RIGHT"},
|
||||
}},
|
||||
}
|
||||
conn.SetStatus(&DeviceStatus{Group: leftFirst})
|
||||
|
||||
rightFirst := &models.Group{
|
||||
ID: "pair-1",
|
||||
MasterDeviceID: "master",
|
||||
Roles: models.GroupRoles{Roles: []models.GroupRole{
|
||||
{DeviceID: "member", Role: "RIGHT"},
|
||||
{DeviceID: "master", Role: "LEFT"},
|
||||
}},
|
||||
}
|
||||
|
||||
if conn.ApplyGroupEvent(rightFirst, time.Now()) {
|
||||
t.Fatal("reordered roles for the same pair must not report a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusSnapshotIsolation(t *testing.T) {
|
||||
// A snapshot returned by Status() must NOT change when a later
|
||||
// UpdateStatus replaces a pointer field. This proves the atomic
|
||||
|
||||
@@ -47,6 +47,12 @@ type DeviceConnection struct {
|
||||
|
||||
status atomic.Pointer[DeviceStatus]
|
||||
|
||||
// groupMu orders polled /getGroup responses against real-time
|
||||
// groupUpdated events. Starting a newer refresh or receiving an event
|
||||
// invalidates any older in-flight poll.
|
||||
groupMu sync.Mutex
|
||||
groupGeneration uint64
|
||||
|
||||
// done is closed by Close when the device is removed from the
|
||||
// registry, signalling its background goroutines (the status poller
|
||||
// and the WebSocket reconnect loop) to exit. closeOnce keeps Close
|
||||
@@ -62,6 +68,7 @@ type DeviceStatus struct {
|
||||
Presets *models.Presets `json:"presets,omitempty"`
|
||||
Sources *models.Sources `json:"sources,omitempty"`
|
||||
Bass *models.Bass `json:"bass,omitempty"`
|
||||
Group *models.Group `json:"group,omitempty"`
|
||||
IsConnected bool `json:"isConnected"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
@@ -85,10 +92,9 @@ func NewDeviceConnection(c *client.Client, info *models.DeviceInfo) *DeviceConne
|
||||
}
|
||||
|
||||
// Status returns a snapshot of the current device status. The returned
|
||||
// pointer is read-only from the caller's perspective; mutating the
|
||||
// pointed-to struct has no effect on the stored status. Use
|
||||
// UpdateStatus or SetStatus to apply changes. Never returns nil for
|
||||
// connections built via NewDeviceConnection.
|
||||
// pointer is read-only from the caller's perspective and must not be
|
||||
// mutated. Use UpdateStatus or SetStatus to apply changes. Never returns
|
||||
// nil for connections built via NewDeviceConnection.
|
||||
func (c *DeviceConnection) Status() *DeviceStatus {
|
||||
return c.status.Load()
|
||||
}
|
||||
@@ -128,7 +134,7 @@ func (c *DeviceConnection) SetStatus(s *DeviceStatus) {
|
||||
// writers cannot silently lose each other's changes.
|
||||
//
|
||||
// The copy mut receives is a shallow value copy of the previous status.
|
||||
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass)
|
||||
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass, Group)
|
||||
// share their backing struct with the previous version: callers MUST
|
||||
// REPLACE these pointers (s.Volume = &models.Volume{...}) rather than
|
||||
// mutate through them (s.Volume.ActualVolume++ would race with any
|
||||
@@ -147,6 +153,61 @@ func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) {
|
||||
}
|
||||
}
|
||||
|
||||
// BeginGroupRefresh starts a new generation for an asynchronous /getGroup
|
||||
// request. Only the latest started request may later update Group.
|
||||
func (c *DeviceConnection) BeginGroupRefresh() uint64 {
|
||||
c.groupMu.Lock()
|
||||
defer c.groupMu.Unlock()
|
||||
|
||||
c.groupGeneration++
|
||||
|
||||
return c.groupGeneration
|
||||
}
|
||||
|
||||
// ApplyPolledGroup stores a /getGroup result only when no newer poll or
|
||||
// groupUpdated event superseded it. Empty groups clear the current claim.
|
||||
func (c *DeviceConnection) ApplyPolledGroup(generation uint64, group *models.Group) bool {
|
||||
c.groupMu.Lock()
|
||||
defer c.groupMu.Unlock()
|
||||
|
||||
if generation != c.groupGeneration {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.replaceGroup(normalizeGroup(group), time.Time{})
|
||||
}
|
||||
|
||||
// ApplyGroupEvent stores the newest groupUpdated event and invalidates all
|
||||
// in-flight /getGroup requests. Empty teardown events clear the current claim.
|
||||
func (c *DeviceConnection) ApplyGroupEvent(group *models.Group, activity time.Time) bool {
|
||||
c.groupMu.Lock()
|
||||
defer c.groupMu.Unlock()
|
||||
|
||||
c.groupGeneration++
|
||||
|
||||
return c.replaceGroup(normalizeGroup(group), activity)
|
||||
}
|
||||
|
||||
func (c *DeviceConnection) replaceGroup(group *models.Group, activity time.Time) bool {
|
||||
changed := !models.SameGroup(c.Status().Group, group)
|
||||
c.UpdateStatus(func(s *DeviceStatus) {
|
||||
s.Group = group
|
||||
if !activity.IsZero() {
|
||||
s.LastActivity = activity
|
||||
}
|
||||
})
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
func normalizeGroup(group *models.Group) *models.Group {
|
||||
if group == nil || group.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
// APIResponse is a standard JSON response wrapper
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
@@ -21,7 +21,11 @@ If your device doesn't expose the port, you can still use the on-device installe
|
||||
|
||||
The storage space on the SoundTouch devices is very limited — stock rootfs typically has only a few MB free (e.g. ~4 MB on the ST20, see issue #268), well below the AfterTouch binary's ~12 MB. To work around this, the installer puts everything on `/mnt/nv/aftertouch` by default (the persistent partition, typically ~30 MB free) and points `/opt/aftertouch` at it via a symlink so the init script and runtime paths stay unchanged. Override the install target with `INSTALL_DIR=/some/path` if you've got room elsewhere.
|
||||
|
||||
The space limitation also means we are currently unsure on how to update the system, because two binaries are already too large. We are currently working on this - both by checking how we can make the binaries smaller, but also on how we can extend the storage space (e.g. by running AfterTouch from a USB drive).
|
||||
Updating is genuinely tight on this partition, since the old binary, the new one, and a rollback backup can't all comfortably fit at once, and binaries only keep growing (the Go toolchain's own defaults alone add hundreds of KB per major version, independent of anything in this project). The installer handles this in a few ways:
|
||||
- The rollback backup is gzip-compressed (`.backup.gz`) rather than a plain copy, cutting its footprint by roughly a third.
|
||||
- Before downloading anything, it checks whether there's actually enough free space for the update, using the new binary's real size (a HEAD request), not a guess.
|
||||
- If there's enough room for the update itself but not enough extra for a backup, it asks for confirmation before proceeding without one — reading from `/dev/tty` since the installer is normally run as `curl | sh`. The default (empty input, or no `/dev/tty` available at all) is always to abort rather than silently skip the backup; set `AFTERTOUCH_FORCE_NO_BACKUP=yes` to skip that prompt for unattended/scripted installs.
|
||||
- If there isn't even enough room for the update itself, it aborts before downloading anything, rather than leaving a partially-overwritten, non-executable binary in place.
|
||||
|
||||
### Logs
|
||||
|
||||
@@ -177,14 +181,39 @@ redirect to discover the newest tag. If that lookup fails (offline, or a `curl`
|
||||
build without `-w` support), it falls back to a pinned version baked into the
|
||||
script.
|
||||
|
||||
> **Tip — rollback:** if the new binary misbehaves, the installer left a `.backup` file alongside it:
|
||||
> **Tip — rollback:** if the new binary misbehaves, the installer left a backup file
|
||||
> alongside it, gzip-compressed as `.backup.gz` (plain `.backup`, uncompressed, if
|
||||
> `gzip` wasn't available on your device):
|
||||
> ```bash
|
||||
> ls /mnt/nv/aftertouch/aftertouch-service*.backup
|
||||
> ls /mnt/nv/aftertouch/aftertouch-service*.backup*
|
||||
> # .backup.gz (compressed):
|
||||
> gunzip -c /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup.gz \
|
||||
> > /mnt/nv/aftertouch/aftertouch-service
|
||||
> chmod +x /mnt/nv/aftertouch/aftertouch-service
|
||||
> # or, for an uncompressed .backup:
|
||||
> cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
|
||||
> /mnt/nv/aftertouch/aftertouch-service
|
||||
> /etc/init.d/aftertouch restart
|
||||
> ```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All of these go on `sh`, not `curl` — in a pipe, each command is a separate
|
||||
process, so `VAR=X curl ... | sh` silently does NOT set it for `sh` (the one
|
||||
that actually reads it). Use `curl -sSL .../install.sh | VAR=X sh` instead.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|------------------------------|-----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `VERSION` | resolved automatically to the latest GitHub release | Install a specific version instead of latest. See [Updating AfterTouch](#updating-aftertouch) for the three equivalent ways to set this. |
|
||||
| `INSTALL_DIR` | `/mnt/nv/aftertouch` | Where AfterTouch is installed. `/opt/aftertouch` is symlinked here so the init script's hardcoded path keeps working. |
|
||||
| `AFTERTOUCH_FORCE_NO_BACKUP` | unset | Skip the interactive "not enough space for a backup, continue anyway?" prompt and proceed without a rollback backup. For unattended/scripted installs only — interactively, the installer always asks (or aborts if no terminal is available) rather than silently skipping the backup. |
|
||||
| `AFTERTOUCH_LAN_PORT` | `auto` | Written into `aftertouch.conf` (not consumed by the installer itself beyond that). Controls whether/which LAN entry-port gets redirected to AfterTouch. See [Model Support Matrix](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md). |
|
||||
| `UPDATE_TMP_DIR` | `/media/aftertouch` | Scratch directory for the downloaded binary before it replaces the installed one. Should stay on a different filesystem than `INSTALL_DIR` (tmpfs by default) so the download itself doesn't compete with `INSTALL_DIR` for space. |
|
||||
| `GH_REPO` | `gesellix/Bose-SoundTouch` | Install from a fork instead, e.g. for testing an unmerged branch's release. |
|
||||
| `BINARY_URL` | derived from `GH_REPO`/`VERSION` | Override the service binary's download URL entirely, bypassing `GH_REPO`/`VERSION` for this one file. |
|
||||
| `INIT_SCRIPT_URL` | derived from `GH_REPO`/`VERSION` | Override the init script's download URL entirely, bypassing `GH_REPO`/`VERSION` for this one file. |
|
||||
| `FALLBACK_VERSION` | `0.123.0` | Used only if the latest-release lookup fails (offline, rate-limited, or a `curl` build without `-w` support). |
|
||||
|
||||
## Uninstallation
|
||||
|
||||
Before uninstall, you might want to revert the migration, especially the changes to the server URLs (even though having configured an unresponsive local server probably is about as bad as having configured unresponsive Bose servers). To uninstall AfterTouch, run the following command on the speaker.
|
||||
|
||||
@@ -97,6 +97,7 @@ fi
|
||||
# prior run was interrupted.
|
||||
echo "Disk usage before pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.backup.gz \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
[ -f "$f" ] || continue
|
||||
@@ -105,6 +106,86 @@ for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
done
|
||||
echo "Disk usage after pre-install GC:"; df -h "$INSTALL_DIR"
|
||||
|
||||
# --- Preflight disk-space check ------------------------------------------
|
||||
# /mnt/nv is small (tens of MB) and binaries keep growing (Go 1.27 alone
|
||||
# added ~640KB to this binary via its own new stdlib defaults, unrelated to
|
||||
# this project's code). A prior attempt on real hardware ran out of space
|
||||
# mid-replace and left a truncated, non-executable binary in place: UBIFS is
|
||||
# a log-structured flash filesystem, so space freed by overwriting the old
|
||||
# binary isn't necessarily reusable by the time the new one needs to land.
|
||||
# Check upfront, with a safety margin, instead of discovering this mid-write.
|
||||
#
|
||||
# The new binary's size comes from a HEAD request rather than a hardcoded
|
||||
# threshold, so this doesn't go stale as binaries grow across releases.
|
||||
NEW_BINARY_BYTES=$(curl -sSLI --fail "$BINARY_URL" 2>/dev/null \
|
||||
| tr -d '\r' \
|
||||
| awk 'tolower($1) == "content-length:" {v=$2} END {print v}') || true
|
||||
|
||||
AVAILABLE_KB=$(df -Pk "$INSTALL_DIR" | awk 'NR==2 {print $4}')
|
||||
|
||||
CURRENT_BINARY_KB=0
|
||||
if [ -f "$INSTALL_DIR/aftertouch-service" ]; then
|
||||
CURRENT_BINARY_KB=$(du -k "$INSTALL_DIR/aftertouch-service" | awk '{print $1}')
|
||||
fi
|
||||
|
||||
# Flat margin, not a percentage: covers UBIFS's own reserved/GC headroom on
|
||||
# this log-structured flash filesystem plus general slack.
|
||||
SAFETY_MARGIN_KB=5120 # 5 MB
|
||||
|
||||
SKIP_BACKUP=no
|
||||
|
||||
if [ -n "$NEW_BINARY_BYTES" ]; then
|
||||
NEW_BINARY_KB=$((NEW_BINARY_BYTES / 1024))
|
||||
# Backups compress to roughly 70% of the original size in practice
|
||||
# (observed: a ~14.8MB binary gzipped to ~10.1MB); used as a conservative
|
||||
# estimate since the real ratio isn't known until compression actually runs.
|
||||
BACKUP_ESTIMATE_KB=$((CURRENT_BINARY_KB * 7 / 10))
|
||||
|
||||
NEEDED_WITH_BACKUP_KB=$((NEW_BINARY_KB + BACKUP_ESTIMATE_KB + SAFETY_MARGIN_KB))
|
||||
NEEDED_NO_BACKUP_KB=$((NEW_BINARY_KB + SAFETY_MARGIN_KB))
|
||||
|
||||
if [ "$AVAILABLE_KB" -ge "$NEEDED_WITH_BACKUP_KB" ]; then
|
||||
: # plenty of room; proceed normally, with a backup
|
||||
elif [ "$AVAILABLE_KB" -ge "$NEEDED_NO_BACKUP_KB" ]; then
|
||||
echo "WARNING: not enough free space on $INSTALL_DIR to keep a rollback" >&2
|
||||
echo "backup this time (${AVAILABLE_KB}KB available; ~${NEEDED_WITH_BACKUP_KB}KB" >&2
|
||||
echo "wanted with a backup, ~${NEEDED_NO_BACKUP_KB}KB without one)." >&2
|
||||
echo "Continuing will replace the current binary with NO way to" >&2
|
||||
echo "automatically undo it if something goes wrong." >&2
|
||||
if [ -n "${AFTERTOUCH_FORCE_NO_BACKUP:-}" ]; then
|
||||
echo "Proceeding without a backup (AFTERTOUCH_FORCE_NO_BACKUP is set)." >&2
|
||||
SKIP_BACKUP=yes
|
||||
elif [ -r /dev/tty ] && [ -w /dev/tty ]; then
|
||||
printf 'Continue without a backup? [y/N] ' > /dev/tty
|
||||
REPLY=""
|
||||
read -r REPLY < /dev/tty || true
|
||||
case "$REPLY" in
|
||||
[Yy]*) SKIP_BACKUP=yes ;;
|
||||
*)
|
||||
echo "Aborting: refusing to proceed without a backup. Free up space" >&2
|
||||
echo "on $INSTALL_DIR and try again, or set AFTERTOUCH_FORCE_NO_BACKUP=yes" >&2
|
||||
echo "to proceed without one non-interactively." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "No interactive terminal available to confirm; aborting." >&2
|
||||
echo "Set AFTERTOUCH_FORCE_NO_BACKUP=yes to proceed without a backup" >&2
|
||||
echo "non-interactively." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: not enough free space on $INSTALL_DIR to install AfterTouch" >&2
|
||||
echo "$VERSION safely (${AVAILABLE_KB}KB available, ~${NEEDED_NO_BACKUP_KB}KB" >&2
|
||||
echo "needed). Free up space and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: could not determine the new binary's size ahead of time" >&2
|
||||
echo "(HEAD request to $BINARY_URL failed); skipping the preflight" >&2
|
||||
echo "disk-space check." >&2
|
||||
fi
|
||||
|
||||
curl \
|
||||
-sSL \
|
||||
-o "$UPDATE_TMP_DIR/binary" \
|
||||
@@ -114,15 +195,34 @@ curl \
|
||||
# Back up the current binary before overwriting so a one-step rollback
|
||||
# is always available. The version string comes from the binary itself;
|
||||
# if it is absent (very old build or corrupted) we fall back to a timestamp.
|
||||
# Skipped entirely when the preflight check above decided (with the
|
||||
# operator's explicit confirmation, or AFTERTOUCH_FORCE_NO_BACKUP) that
|
||||
# there isn't room for one.
|
||||
BACKUP_FILE=""
|
||||
if [ -f "$INSTALL_DIR/aftertouch-service" ]; then
|
||||
if [ -f "$INSTALL_DIR/aftertouch-service" ] && [ "$SKIP_BACKUP" != "yes" ]; then
|
||||
current_version=$("$INSTALL_DIR/aftertouch-service" --version 2>/dev/null \
|
||||
| awk '{print $NF}') || true
|
||||
if [ -z "$current_version" ] || [ "$current_version" = "dev" ]; then
|
||||
current_version=$(date +%Y%m%d-%H%M%S)
|
||||
fi
|
||||
# Binaries are tens of MB and only growing (see #614 investigation into
|
||||
# Go 1.27's default binary-size increase), while /mnt/nv is small (tens of
|
||||
# MB total). Stream straight into the compressed file rather than cp-then-
|
||||
# gzip: at this point in the script the old binary is still live AND the
|
||||
# newly-downloaded one is already sitting in $UPDATE_TMP_DIR, so an
|
||||
# intermediate uncompressed backup copy would briefly need all three full
|
||||
# copies on disk at once -- exactly the kind of moment that has already
|
||||
# caused "no space left on device" failures here. Best effort: if gzip is
|
||||
# missing, or the stream fails partway (e.g. disk fills mid-compress),
|
||||
# fall back to a plain uncompressed copy exactly as before.
|
||||
BACKUP_FILE="$INSTALL_DIR/aftertouch-service.${current_version}.backup"
|
||||
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
|
||||
if command -v gzip >/dev/null 2>&1 \
|
||||
&& gzip -c < "$INSTALL_DIR/aftertouch-service" > "$BACKUP_FILE.gz"; then
|
||||
BACKUP_FILE="$BACKUP_FILE.gz"
|
||||
else
|
||||
rm -f "$BACKUP_FILE.gz"
|
||||
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
|
||||
fi
|
||||
echo "Backed up current binary ($current_version) → $BACKUP_FILE"
|
||||
fi
|
||||
|
||||
@@ -136,6 +236,7 @@ chmod +x "$INSTALL_DIR/aftertouch-service"
|
||||
if [ -n "$BACKUP_FILE" ]; then
|
||||
echo "Disk usage before post-install GC:"; df -h "$INSTALL_DIR"
|
||||
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
|
||||
"$INSTALL_DIR/aftertouch-service".*.backup.gz \
|
||||
"$INSTALL_DIR/aftertouch-service".*.old \
|
||||
"$INSTALL_DIR/aftertouch-service.new"; do
|
||||
[ -f "$f" ] || continue
|
||||
|
||||
@@ -5,18 +5,8 @@ set -e
|
||||
LIB_DIR="pkg/service/soundtouchweb/static/lib"
|
||||
mkdir -p "$LIB_DIR"
|
||||
|
||||
echo "Updating static frontend dependencies from node_modules..."
|
||||
|
||||
# Ensure dependencies are installed
|
||||
if [ ! -d "node_modules" ] || [ ! -d "node_modules/preact" ] || [ ! -d "node_modules/htm" ]; then
|
||||
if [ "$CI" = "true" ]; then
|
||||
echo "node_modules not found or incomplete. Running npm ci in CI environment..."
|
||||
npm ci
|
||||
else
|
||||
echo "node_modules not found or incomplete. Running npm install..."
|
||||
npm install
|
||||
fi
|
||||
fi
|
||||
echo "Installing static frontend dependencies from package-lock.json..."
|
||||
npm ci --ignore-scripts
|
||||
|
||||
# Copy files from node_modules
|
||||
echo "Copying Preact..."
|
||||
@@ -28,4 +18,19 @@ cp node_modules/preact/hooks/dist/hooks.module.js "$LIB_DIR/preact-hooks.module.
|
||||
echo "Copying HTM..."
|
||||
cp node_modules/htm/dist/htm.module.js "$LIB_DIR/htm.module.js"
|
||||
|
||||
# Polyfills import map support for browsers that have ES modules but not
|
||||
# import maps (e.g. Safari on iPadOS 15, see #649). Safe to always vendor:
|
||||
# it detects native import map support and becomes a no-op there.
|
||||
echo "Copying ES Module Shims..."
|
||||
cp node_modules/es-module-shims/dist/es-module-shims.js "$LIB_DIR/es-module-shims.js"
|
||||
|
||||
# Keep license and package provenance inside the embedded static tree so they
|
||||
# ship with release binaries, not only with source checkouts.
|
||||
LICENSE_DIR="$LIB_DIR/LICENSES"
|
||||
mkdir -p "$LICENSE_DIR"
|
||||
for dependency in preact htm es-module-shims; do
|
||||
cp "node_modules/$dependency/LICENSE" "$LICENSE_DIR/$dependency-LICENSE"
|
||||
done
|
||||
cp package-lock.json "$LICENSE_DIR/package-lock.json"
|
||||
|
||||
echo "All dependencies updated successfully from node_modules."
|
||||
|
||||
Reference in New Issue
Block a user