Compare commits

..
2 Commits
Author SHA1 Message Date
Tobias Gesellchen b3ff98290b feat(soundtouchweb): make #622 auto-resume opt-in via settings.json
Automatically re-triggering content selection without a user action
isn't something every operator wants, and we haven't independently
confirmed the root cause generalises beyond the original report.

Add Settings.AutoResumeOnSourceDisconnect (default false, hand-edit
settings.json to enable, matching the TuneInStreamFormats precedent -
no admin UI control yet). Wired through a WebApp hook so the standalone
soundtouch-player build stays unaffected, and read fresh per drop so
toggling the setting takes effect without a restart.
2026-08-18 21:38:33 +02:00
Tobias Gesellchen 133ba5a616 fix(soundtouchweb): auto-resume playback after an unsolicited SOURCE_DISCONNECTED (#622)
A speaker can drop its own active source mid-playback (errorUpdate 1041
SOURCE_DISCONNECTED -> now_playing INVALID_SOURCE) while the SoundTouch
WebSocket control channel stays healthy throughout. Nothing previously
noticed this: logNowPlayingError only logged the transition, leaving
the speaker silent until someone manually re-selected the source.

Add autoResumeState, tracking the last healthy ContentItem per device
connection. On a fresh transition into an error source (not a repeat
of one already seen), it re-issues that ContentItem via SelectContentItem
after a short backoff - the same call pressing the preset again makes.
No attempt cap: if the resume itself fails, the source stays in error
and nothing fires again until a genuine recovery is observed, which
already bounds retries for a station that's truly gone without capping
a station that legitimately (and repeatedly) recovers on its own.
2026-08-18 21:38:33 +02:00
92 changed files with 1154 additions and 6413 deletions
-18
View File
@@ -1,18 +0,0 @@
# 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"
-49
View File
@@ -1,49 +0,0 @@
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
+1 -1
View File
@@ -308,7 +308,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Set build date
id: build_date
+3 -3
View File
@@ -40,17 +40,17 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || matrix.language == 'javascript-typescript' && './.github/codeql-config-js.yml' || '' }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:${{ matrix.language }}"
+3 -63
View File
@@ -346,14 +346,6 @@ jobs:
TAG_NAME="${{ needs.validate.outputs.tag }}"
VERSION="${TAG_NAME#v}"
# Real per-platform links for the two most-used tools, generated
# from the deterministic `<binary>-<tag>-<os>-<arch>[.exe]` asset
# naming convention (see scripts/release/quick-downloads.sh),
# instead of requiring a scroll through the flat, alphabetical
# Assets list. Inline checksum link per row (à la Helm's release
# notes) instead of sending people to the combined checksums file.
QUICK_DOWNLOADS="$(scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}")"
# Short, accurate header. GitHub's auto-generated "What's Changed"
# + "Full Changelog" are appended after this (generate_release_notes).
cat > release_notes.md << EOF
@@ -361,15 +353,13 @@ jobs:
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
$QUICK_DOWNLOADS
## What's included
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
- **soundtouch-service** (see above)
- **soundtouch-cli** (see above)
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
Not sure which file to grab? The [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/) explains which tool you need and which \`<os>-<arch>\` build matches your computer.
@@ -424,62 +414,12 @@ jobs:
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Download release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-assets
path: ./release-assets
- name: Upgrade the Downloads footer with direct per-platform links
# This is the path real releases take: a maintainer hand-writes
# "Noteworthy" notes and publishes via the GitHub web UI, which
# fires this job, not create_release (workflow_dispatch only).
# _/releases/_TEMPLATE.md's convention is a trailing footer line:
# ---
# 📦 **Downloads / installation:** <downloads page URL>
# Drop that line (if present) and append the quick-downloads
# block in its place. Always goes through the same append path
# (strip block + strip footer + append), whether or not a
# footer line is still there, so re-runs stay byte-for-byte
# idempotent instead of drifting on the 2nd run.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ needs.validate.outputs.tag }}"
scripts/release/quick-downloads.sh "$TAG_NAME" "${{ github.repository }}" > quick_downloads.md
gh release view "$TAG_NAME" --json body -q .body > existing_body.md
python3 - << 'PYEOF'
import re
with open("existing_body.md") as f:
body = f.read()
with open("quick_downloads.md") as f:
block = f.read().rstrip("\n")
# Drop a block this automation inserted on a previous run.
body = re.sub(r"\n*<!-- quick-downloads:start -->.*?<!-- quick-downloads:end -->\n*", "\n", body, flags=re.DOTALL)
# Drop the hand-authored footer line (first run only) so both
# cases converge on the same append below and re-runs stay
# byte-for-byte idempotent.
footer = re.compile(r"^📦 \*\*Downloads / installation:\*\*.*\n?", re.MULTILINE)
body = footer.sub("", body, count=1)
body = body.rstrip("\n") + "\n\n" + block + "\n"
with open("combined_notes.md", "w") as f:
f.write(body)
PYEOF
gh release edit "$TAG_NAME" --notes-file combined_notes.md
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
@@ -514,7 +454,7 @@ jobs:
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: semgrep.sarif
continue-on-error: true
+1 -1
View File
@@ -1,5 +1,5 @@
# golangci-lint configuration for Bose SoundTouch Go Library
# Compatible with golangci-lint v2.13.1
# Compatible with golangci-lint v2.8.0
# See: https://golangci-lint.run/usage/configuration/
version: "2"
+1 -1
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.27.0-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.6-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
+2 -12
View File
@@ -1,4 +1,4 @@
.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
.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
# Load .env if present (simple KEY=VALUE format, no shell quoting)
-include .env
@@ -147,15 +147,6 @@ 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
@@ -252,7 +243,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/v2/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/cmd/golangci-lint@latest" && exit 1)
golangci-lint run
tidy:
@@ -510,7 +501,6 @@ 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"
-13
View File
@@ -1,13 +0,0 @@
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
}
+3 -3
View File
@@ -654,8 +654,8 @@ func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
r.Body = io.NopCloser(bytes.NewReader(body))
browseAttrs = []any{
"objectID", sanitizeLog(between(string(body), "<ObjectID>", "</ObjectID>")),
"browseFlag", sanitizeLog(between(string(body), "<BrowseFlag>", "</BrowseFlag>")),
"objectID", between(string(body), "<ObjectID>", "</ObjectID>"),
"browseFlag", 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", sanitizeLog(r.URL.Path),
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"from", r.RemoteAddr,
+6 -7
View File
@@ -15,7 +15,6 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/urfave/cli/v2"
"golang.org/x/term"
@@ -675,9 +674,9 @@ func setupEnableSSHCmd() *cli.Command {
},
&cli.StringFlag{
Name: "account",
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: account ID to pair with " +
"(empty = generate a fresh 7-digit one). Use this if you already know which account this device " +
"should end up on (e.g. to match one already in the datastore) rather than getting a random one now",
Usage: "Only used when the device is unpaired and --no-auto-pair is not set: 7-digit account ID to pair " +
"with (empty = generate one). Use this if you already know which account this device should end up " +
"on (e.g. to match one already in the datastore) rather than getting a random one now",
},
&cli.BoolFlag{
Name: "no-reset-urls",
@@ -1975,7 +1974,7 @@ func setupPairCmd() *cli.Command {
Usage: "Pair the speaker with an account via WebSocket SETUP state machine",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{Name: "account", Usage: "Account ID to pair with (empty = generate a fresh 7-digit one)"},
&cli.StringFlag{Name: "account", Usage: "7-digit account ID (empty = generate)"},
&cli.StringFlag{Name: "mode", Value: "full", Usage: "full (state machine) or bare (setMargeAccount only — experimental)"},
&cli.StringFlag{Name: "service-url", Value: "http://aftertouch.local:8000", Usage: "AfterTouch base URL (also populates <boseServer>/<updateServer> in setMargeAccount)"},
&cli.StringFlag{Name: "name", Usage: "Speaker name to set during pairing (empty = keep current)"},
@@ -1999,8 +1998,8 @@ func setupPairCmd() *cli.Command {
fmt.Printf("Generated account id: %s\n", accountID)
}
if !datastore.IsSafeIdentifier(accountID) {
return fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", accountID)
if !setup.IsValidAccountID(accountID) {
return fmt.Errorf("invalid account id %q: must be 7 digits", accountID)
}
switch mode {
+2 -2
View File
@@ -88,7 +88,7 @@ go build -o soundtouch-player
./soundtouch-player -port 8888
# Connect to specific device
./soundtouch-player --devices 192.0.2.100
./soundtouch-player -host 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
- **Configured devices** via `--devices`, retried whenever discovery runs
- **Manual device addition** via IP address
### Real-time Updates
The interface maintains WebSocket connections to each device for instant updates of:
+1 -6
View File
@@ -161,7 +161,7 @@ func main() {
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
}
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
// Discover devices on startup
go func() {
@@ -170,11 +170,6 @@ 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")
}
+118 -151
View File
@@ -300,18 +300,6 @@ 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)",
@@ -668,7 +656,7 @@ func main() {
}
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds, config.deviceSeedRetryInterval, config.deviceSeedRetryWindow)
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
r := setupRouter(server, stockholmHandler, webApp)
@@ -727,57 +715,55 @@ 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
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
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
}
// resolveFallbackHost picks the host used to guess a server URL when
@@ -874,24 +860,6 @@ 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")
@@ -930,57 +898,55 @@ 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,
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,
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,
}, nil
}
@@ -1461,7 +1427,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, deviceSeedRetryInterval, deviceSeedRetryWindow time.Duration) *soundtouchweb.WebApp {
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
webApp := soundtouchweb.NewWebApp()
webApp.Version = version
webApp.Commit = commit
@@ -1478,10 +1444,11 @@ 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, error) {
webApp.ExtraDeviceHosts = func() []string {
devices, listErr := ds.ListAllDevices()
if listErr != nil {
return nil, fmt.Errorf("web UI: failed to list devices from datastore: %w", listErr)
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
return nil
}
hosts := make([]string, 0, len(devices))
@@ -1491,7 +1458,7 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
}
}
return hosts, nil
return hosts
}
// UI "discover" runs the service's sweep, not a second mDNS stack.
@@ -1504,6 +1471,17 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
return err
}
// Opt-in (#622): hand-edit settings.json's auto_resume_on_source_disconnect
// to enable. Read fresh per drop so toggling it applies without a restart.
webApp.AutoResumeOnSourceDisconnect = func() bool {
settings, err := ds.GetSettings()
if err != nil {
return false
}
return settings.AutoResumeOnSourceDisconnect
}
// Keep the UI registry live as the service discovers or devices are added.
server.SetDevicesChangedHook(func() {
webApp.SeedExtraDevices()
@@ -1511,20 +1489,9 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
})
go func() {
// 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.
// 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()
webApp.BroadcastDeviceList()
}()
-52
View File
@@ -6,7 +6,6 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/urfave/cli/v2"
@@ -70,57 +69,6 @@ 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"))
+5 -2
View File
@@ -39,8 +39,11 @@ func TestPrintRoutes(t *testing.T) {
// Now we might have "soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
// Remove the leading package/binary-name segment(s), whatever form
// they take.
// 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
for {
dotIdx := strings.Index(handlerName, ".")
if dotIdx == -1 {
+1 -2
View File
@@ -46,7 +46,6 @@ 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
@@ -170,7 +169,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/* handlers.(*Server).HandleWeb
GET /web/* setupRouter.(*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
+3 -3
View File
@@ -35,7 +35,7 @@ services:
start_period: 3s
spotify-mock:
image: golang:1.27.0-alpine
image: golang:1.26.6-alpine
container_name: spotify-mock
working_dir: /app
volumes:
@@ -53,7 +53,7 @@ services:
start_period: 3s
amazon-mock:
image: golang:1.27.0-alpine
image: golang:1.26.6-alpine
container_name: amazon-mock
working_dir: /app
volumes:
@@ -71,7 +71,7 @@ services:
start_period: 3s
tunein-mock:
image: golang:1.27.0-alpine
image: golang:1.26.6-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.27.0-alpine
FROM golang:1.25-alpine
WORKDIR /app
COPY . .
@@ -101,13 +101,13 @@ rename and network/firmware info.
---
## 4. Render stereo pairs as a single device (shipped)
## 4. Render stereo pairs as a single device
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.
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>.
**Device API:**
- `GET /getGroup` on each speaker — returns the current `<group>` with
@@ -118,30 +118,28 @@ registry.
side is sufficient to detect the pair
**Backend:**
- 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.
- 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
**Frontend:**
- 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.
- 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
**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.
**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.
---
+6 -11
View File
@@ -17,17 +17,12 @@ then **which build** matches your computer.
AfterTouch is a small set of separate programs. Most people run one or
two of them.
| Tool | What it does | You want this if… |
|----------------------|------------------------------------------------------------------------------------------------|---------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
Most people only need **`soundtouch-service`** and **`soundtouch-cli`** — the
release notes on each [GitHub release](https://github.com/gesellix/Bose-SoundTouch/releases/latest)
link those two directly, one row per platform, so you don't have to hunt
through the flat Assets list below.
| Tool | What it does | You want this if… |
|----------------------|-----------------------------------------------------------------------------------------------|----------------------------------------------------------|
| `soundtouch-service` | The local cloud replacement ("AfterTouch"). Runs always-on and takes over from the Bose cloud. | You are migrating speakers off the Bose cloud. |
| `soundtouch-player` | A browser control panel (radio browsing, device control). | You want a web UI to browse radio and control speakers. |
| `soundtouch-cli` | Command-line control and setup (status, play, presets, groups, **migration**, …). | You want to script things, or run a migration by hand. |
| `soundtouch-backup` | Backs up your Bose cloud account and each speaker's local state. | You are preparing before a shutdown / factory reset. |
> Running a migration from the command line (for example the telnet
> re-migration in the
+84 -84
View File
@@ -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.27.0-alpine AS builder
FROM golang:1.25-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
+27 -29
View File
@@ -156,35 +156,33 @@ 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_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)* |
| 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)* |
### Configuration Examples
@@ -594,28 +594,6 @@ 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:**
+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.27.0
go 1.26.6
require github.com/gesellix/bose-soundtouch v0.128.0
require github.com/gesellix/bose-soundtouch v0.123.0
require github.com/gorilla/websocket v1.5.3 // indirect
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.27.0
go 1.26.6
require github.com/gesellix/bose-soundtouch v0.128.0
require github.com/gesellix/bose-soundtouch v0.123.0
require github.com/gorilla/websocket v1.5.3 // indirect
+5 -3
View File
@@ -1,15 +1,15 @@
module github.com/gesellix/bose-soundtouch
go 1.27.0
go 1.26.6
require (
filippo.io/age v1.3.1
github.com/chromedp/chromedp v0.16.0
github.com/go-chi/chi/v5 v5.3.2
github.com/go-chi/chi/v5 v5.3.1
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.73
github.com/miekg/dns v1.1.72
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,6 +33,8 @@ 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
)
+8 -4
View File
@@ -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.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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-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,6 +27,8 @@ 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=
@@ -38,8 +40,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.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
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/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=
@@ -87,6 +89,8 @@ 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=
-7
View File
@@ -7,7 +7,6 @@
"name": "@gesellix/bose-soundtouch",
"license": "MIT",
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.8"
},
@@ -15,12 +14,6 @@
"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",
-1
View File
@@ -9,7 +9,6 @@
"node": ">=24.0.0"
},
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.8"
}
+4 -12
View File
@@ -1428,18 +1428,10 @@ 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. 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.
// 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.
func (c *Client) GetGroup() (*models.Group, error) {
var g models.Group
+1 -41
View File
@@ -1,9 +1,6 @@
package models
import (
"encoding/xml"
"strings"
)
import "encoding/xml"
// Group represents a stereo pair of two ST10 SoundTouch speakers.
type Group struct {
@@ -35,40 +32,3 @@ 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
}
+26 -30
View File
@@ -211,41 +211,37 @@ 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":
topItems = append(topItems, tuneInNavigateLink(m))
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))
}
case "audio":
topItems = append(topItems, tuneInNavigatePlayItem(m))
case "text":
// ignore
// Ignore info text
}
}
-68
View File
@@ -2,78 +2,10 @@ 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
+35 -168
View File
@@ -33,30 +33,11 @@ func exists(path string) bool {
return err == nil
}
// maxSafeIdentifierLength bounds account/device IDs accepted from a
// speaker or third-party pairing tool. Well under typical filesystem
// path-component limits (255 bytes); generous for any realistic
// margeAccountUUID or MAC-derived device ID.
const maxSafeIdentifierLength = 128
// IsSafeIdentifier returns true if the given identifier is safe to use
// as a single path component (for account IDs, device IDs, etc.), and
// safe to embed in the other places these values end up: XML sent to a
// speaker, log lines, and datastore-key comparisons. It rejects empty
// or overlong strings, path separators, and parent directory
// references.
//
// The allowed character set intentionally excludes XML/HTML-special
// characters (`< > & " '`), whitespace, and shell/URL metacharacters
// (see #634's `postSetMargeAccount`, which interpolates an account ID
// into an XML body, and `PairAccount`, which interpolates one into a
// literal `envswitch accountid set <id>` telnet command line) even
// though it accepts more than Bose's own 7-digit account format —
// devices paired via third-party or manual tooling (e.g. the
// USB-stick SSH-enable method) can report arbitrary margeAccountUUID
// values such as "stick@local".
func IsSafeIdentifier(id string) bool {
if id == "" || len(id) > maxSafeIdentifierLength {
// isSafeIdentifier returns true if the given identifier is safe to use
// as a single path component (for account IDs, device IDs, etc.).
// It rejects empty strings, path separators, and parent directory references.
func isSafeIdentifier(id string) bool {
if id == "" {
return false
}
@@ -65,17 +46,14 @@ func IsSafeIdentifier(id string) bool {
return false
}
// Letters, digits, and a conservative set of punctuation seen in
// real-world IDs: underscore, dash, dot, colon (MAC-like IDs), and
// '@' (e.g. "stick@local"). Everything else — including all XML,
// HTML, shell, and URL metacharacters, whitespace, and control
// characters — is rejected.
// Allow a conservative set of characters commonly found in IDs:
// letters, digits, underscore, dash, dot, and colon (for MAC-like IDs).
for i := 0; i < len(id); i++ {
c := id[i]
if (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_' || c == '-' || c == '.' || c == ':' || c == '@' {
c == '_' || c == '-' || c == '.' || c == ':' {
continue
}
@@ -950,10 +928,7 @@ 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) {
ds.fileMutex.RLock()
presets, needsRewrite, err := ds.readPresetsNoLock(account, device)
ds.fileMutex.RUnlock()
presets, needsRewrite, err := ds.readPresetsLocked(account, device)
if err != nil {
return nil, err
}
@@ -969,48 +944,18 @@ func (ds *DataStore) GetPresets(account, device string) ([]models.ServicePreset,
return presets, nil
}
// 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()
// 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()
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] readPresetsNoLock: no Presets.xml at %s — reporting no presets", sanitizeLog(path))
return []models.ServicePreset{}, false, nil
}
@@ -1022,7 +967,7 @@ func (ds *DataStore) readPresetsNoLock(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] readPresetsNoLock: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
return []models.ServicePreset{}, false, nil
}
@@ -1054,7 +999,7 @@ func (ds *DataStore) readPresetsNoLock(account, device string) ([]models.Service
needsRewrite := !bytes.Equal(normalized, data)
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
log.Printf("[Datastore] readPresetsNoLock: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
return []models.ServicePreset{}, false, nil
}
@@ -1108,7 +1053,7 @@ func repairLeakedSource(account, device, label, persistedSource, sourceID string
return persistedSource
}
sources, err := ds.getConfiguredSourcesNoLock(account, device)
sources, err := ds.getConfiguredSourcesLocked(account, device)
if err != nil {
return persistedSource
}
@@ -1134,11 +1079,11 @@ func isLeakedSourceValue(s string) bool {
return s == "" || s == "Audio"
}
// getConfiguredSourcesNoLock is GetConfiguredSources without the
// getConfiguredSourcesLocked 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) getConfiguredSourcesNoLock(account, device string) ([]models.ConfiguredSource, error) {
func (ds *DataStore) getConfiguredSourcesLocked(account, device string) ([]models.ConfiguredSource, error) {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
data, err := ds.rootReadFile(path)
@@ -1186,12 +1131,6 @@ 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
@@ -1355,45 +1294,11 @@ 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)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[Datastore] GetRecents: no Recents.xml at %s — reporting no recents", sanitizeLog(path))
return []models.ServiceRecent{}, nil
}
@@ -1499,12 +1404,6 @@ 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
@@ -1610,7 +1509,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return fmt.Errorf("device ID/name cannot be empty")
}
if !IsSafeIdentifier(device) {
if !isSafeIdentifier(device) {
return fmt.Errorf("invalid device ID")
}
@@ -1618,7 +1517,7 @@ func (ds *DataStore) SaveDeviceInfo(account, device string, info *models.Service
return fmt.Errorf("account ID cannot be empty")
}
if !IsSafeIdentifier(account) {
if !isSafeIdentifier(account) {
return fmt.Errorf("invalid account ID")
}
@@ -1806,10 +1705,6 @@ func (ds *DataStore) SaveAccountInfo(accountID string, info *models.ServiceAccou
return nil
}
if !IsSafeIdentifier(accountID) {
return fmt.Errorf("invalid account ID")
}
dir := ds.AccountDir(accountID)
if err := ds.rootMkdirAll(dir, 0755); err != nil {
return err
@@ -2010,40 +1905,6 @@ 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
@@ -2215,13 +2076,6 @@ 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
@@ -2797,6 +2651,19 @@ type Settings struct {
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
// AutoResumeOnSourceDisconnect, when true, re-issues a device's last
// playing content item if now_playing drops into an error source right
// after a healthy one, instead of leaving the speaker silent until a
// user manually re-selects it. See #622: some TuneIn streams disconnect
// the speaker's own audio pipeline (errorUpdate 1041
// SOURCE_DISCONNECTED) on their own, mid-playback, with the SoundTouch
// WebSocket control channel staying healthy throughout; the observed
// fix is exactly what pressing the preset again does. Opt-in (default
// false): this automatically re-triggers content selection without a
// user action, which not every operator wants. Hand-edit settings.json
// to enable — no admin UI control yet, matching TuneInStreamFormats.
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
// DefaultLanding selects what the root path "/" serves to a browser:
// "chooser" (or empty) — the neutral landing page that links to the
// player and the admin/setup console;
+3 -51
View File
@@ -2,7 +2,6 @@ package datastore
import (
"os"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -20,10 +19,6 @@ func TestIsSafeIdentifier(t *testing.T) {
{"abc-123", true},
{"abc.123", true},
{"00:11:22:33:44:55", true},
// #634: third-party/manual pairing tools (e.g. the USB-stick
// SSH-enable method) can report a non-numeric margeAccountUUID.
{"stick@local", true},
{strings.Repeat("a", maxSafeIdentifierLength), true},
{"", false},
{"/", false},
{"\\", false},
@@ -35,6 +30,7 @@ func TestIsSafeIdentifier(t *testing.T) {
{"a..b", false},
{"a b", false},
{"a!b", false},
{"a@b", false},
{"a#b", false},
{"a$b", false},
{"a%b", false},
@@ -43,17 +39,12 @@ func TestIsSafeIdentifier(t *testing.T) {
{"a*b", false},
{"a(b", false},
{"a)b", false},
{"a<b", false},
{"a>b", false},
{`a"b`, false},
{"a'b", false},
{strings.Repeat("a", maxSafeIdentifierLength+1), false},
}
for _, test := range tests {
result := IsSafeIdentifier(test.id)
result := isSafeIdentifier(test.id)
if result != test.expected {
t.Errorf("IsSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
t.Errorf("isSafeIdentifier(%q) = %v; expected %v", test.id, result, test.expected)
}
}
}
@@ -81,8 +72,6 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
{"acc1", "dev/1", true, "invalid device ID"},
{"acc..1", "dev1", true, "invalid account ID"},
{"acc1", "dev..1", true, "invalid device ID"},
// #634: a non-numeric margeAccountUUID is now accepted.
{"stick@local", "dev1", false, ""},
}
for _, test := range tests {
@@ -96,40 +85,3 @@ func TestSaveDeviceInfo_Validation(t *testing.T) {
}
}
}
func TestSaveAccountInfo_Validation(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "datastore-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
ds := NewDataStore(tmpDir)
tests := []struct {
account string
wantErr bool
errMsg string
}{
{"acc1", false, ""},
// #634: a non-numeric margeAccountUUID reported via
// POST /streaming/account (see HandleMargeCreateAccount) must
// be validated the same way SaveDeviceInfo already validates
// device-reported account IDs.
{"stick@local", false, ""},
{"acc/1", true, "invalid account ID"},
{"acc..1", true, "invalid account ID"},
{"a<b", true, "invalid account ID"},
}
for _, test := range tests {
err := ds.SaveAccountInfo(test.account, &models.ServiceAccountInfo{AccountID: test.account})
if (err != nil) != test.wantErr {
t.Errorf("SaveAccountInfo(%q) error = %v, wantErr %v", test.account, err, test.wantErr)
continue
}
if test.wantErr && err.Error() != test.errMsg {
t.Errorf("SaveAccountInfo(%q) error message = %q, want %q", test.account, err.Error(), test.errMsg)
}
}
}
+5 -90
View File
@@ -203,9 +203,6 @@ 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 {
@@ -217,98 +214,16 @@ func TestHandleTuneInToken(t *testing.T) {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]interface{}
var resp map[string]string
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("Expected a non-empty access_token")
if resp["access_token"] != "test-refresh-token" {
t.Errorf("Expected access_token 'test-refresh-token', got %v", resp["access_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)
if resp["refresh_token"] != "test-refresh-token" {
t.Errorf("Expected refresh_token 'test-refresh-token', got %v", resp["refresh_token"])
}
}
+7 -37
View File
@@ -13,7 +13,6 @@ import (
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
@@ -119,32 +118,8 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
}
}
// 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.
// HandleTuneInToken returns a TuneIn access token.
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"`
@@ -155,23 +130,18 @@ func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
return
}
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,
// 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,
}
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
}
}
+48 -52
View File
@@ -13,7 +13,6 @@ import (
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
@@ -256,12 +255,7 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
if !seenAccounts[dev.AccountID] {
seenAccounts[dev.AccountID] = true
pfx := "http/service/account-" + dev.AccountID
// url.PathEscape, not raw concatenation: account/device IDs can
// contain characters like '@' (#634) that are safe as datastore
// keys but would otherwise need escaping to survive as URL path
// segments intact (e.g. a literal '?' or '#' would truncate the
// path here, though IsSafeIdentifier already excludes those).
acct := base + "/streaming/account/" + url.PathEscape(dev.AccountID)
acct := base + "/streaming/account/" + dev.AccountID
tryAdd(pfx+"/full.xml", acct+"/full")
tryAdd(pfx+"/sources.xml", acct+"/sources")
tryAdd(pfx+"/presets.xml", acct+"/presets")
@@ -272,7 +266,7 @@ func (s *Server) addServiceHTTP(tw *tar.Writer, client *http.Client, devices []m
}
dpfx := "http/service/account-" + dev.AccountID + "/device-" + dev.DeviceID
dpath := base + "/streaming/account/" + url.PathEscape(dev.AccountID) + "/device/" + url.PathEscape(dev.DeviceID)
dpath := base + "/streaming/account/" + dev.AccountID + "/device/" + dev.DeviceID
tryAdd(dpfx+"/presets.xml", dpath+"/presets")
tryAdd(dpfx+"/recents.xml", dpath+"/recents")
}
@@ -679,28 +673,29 @@ func (s *Server) addSystemFiles(tw *tar.Writer) {
// diagSettings is a copy of datastore.Settings with secrets zeroed out so the
// struct can be marshalled into the archive without exposing credentials.
type diagSettings struct {
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
ServerURL string `json:"server_url"`
HTTPSServerURL string `json:"https_server_url,omitempty"`
HTTPSServerURLOverride string `json:"https_server_url_override,omitempty"`
RedactLogs bool `json:"redact_logs"`
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
DiscoveryEnabled bool `json:"discovery_enabled"`
DNSEnabled bool `json:"dns_enabled"`
DNSUpstream []string `json:"dns_upstream,omitempty"`
DNSBindAddr string `json:"dns_bind_addr,omitempty"`
InternalPaths []string `json:"internal_paths,omitempty"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
SpotifyClientID string `json:"spotify_client_id,omitempty"`
SpotifyClientSecret string `json:"spotify_client_secret,omitempty"`
SpotifyRedirectURI string `json:"spotify_redirect_uri,omitempty"`
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TrustForwardedHeaders bool `json:"trust_forwarded_headers,omitempty"`
TrustedProxyCIDRs []string `json:"trusted_proxy_cidrs,omitempty"`
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
AutoResumeOnSourceDisconnect bool `json:"auto_resume_on_source_disconnect,omitempty"`
}
// addSettingsJSON serialises the service settings into the archive as
@@ -779,28 +774,29 @@ func (s *Server) addSettingsJSON(tw *tar.Writer) {
_, effectiveHTTPSURL := s.GetSettings()
ds := diagSettings{
ServerURL: st.ServerURL,
HTTPSServerURL: effectiveHTTPSURL,
HTTPSServerURLOverride: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
ServerURL: st.ServerURL,
HTTPSServerURL: effectiveHTTPSURL,
HTTPSServerURLOverride: st.HTTPServerURL,
RedactLogs: st.RedactLogs,
LogBodies: st.LogBodies,
RecordInteractions: st.RecordInteractions,
DiscoveryInterval: st.DiscoveryInterval,
DiscoveryEnabled: st.DiscoveryEnabled,
DNSEnabled: st.DNSEnabled,
DNSUpstream: st.DNSUpstream,
DNSBindAddr: st.DNSBindAddr,
InternalPaths: st.InternalPaths,
Shortcuts: st.Shortcuts,
SpotifyClientID: st.SpotifyClientID,
SpotifyClientSecret: redact(st.SpotifyClientSecret),
SpotifyRedirectURI: st.SpotifyRedirectURI,
AmazonClientID: st.AmazonClientID,
AmazonClientSecret: redact(st.AmazonClientSecret),
AmazonRedirectURI: st.AmazonRedirectURI,
TrustForwardedHeaders: st.TrustForwardedHeaders,
TrustedProxyCIDRs: st.TrustedProxyCIDRs,
TuneInStreamFormats: st.TuneInStreamFormats,
AutoResumeOnSourceDisconnect: st.AutoResumeOnSourceDisconnect,
}
data, err := json.MarshalIndent(ds, "", " ")
-6
View File
@@ -14,7 +14,6 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
"github.com/go-chi/chi/v5"
)
@@ -65,11 +64,6 @@ func (s *Server) HandleMargeCreateAccount(w http.ResponseWriter, r *http.Request
}
}
if !datastore.IsSafeIdentifier(id) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
info := &models.ServiceAccountInfo{
AccountID: id,
PreferredLanguage: req.PreferredLanguage,
+5 -6
View File
@@ -6,7 +6,6 @@ import (
"net/http"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/go-chi/chi/v5"
@@ -62,12 +61,12 @@ type pairAccountResponse struct {
Error string `json:"error,omitempty"`
}
// HandlePairAccount associates the device with the supplied account ID,
// HandlePairAccount associates the device with the supplied 7-digit account ID,
// trying HTTP /setMargeAccount first and falling back to telnet
// `envswitch accountid set`.
//
// Query params:
// - account_id (required) — must pass datastore.IsSafeIdentifier
// - account_id (required) — must pass setup.IsValidAccountID
func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
@@ -76,8 +75,8 @@ func (s *Server) HandlePairAccount(w http.ResponseWriter, r *http.Request) {
}
accountID := r.URL.Query().Get("account_id")
if !datastore.IsSafeIdentifier(accountID) {
writeJSONError(w, http.StatusBadRequest, "account_id must be a non-empty, path-safe identifier")
if !setup.IsValidAccountID(accountID) {
writeJSONError(w, http.StatusBadRequest, "account_id must be exactly 7 digits")
return
}
@@ -146,7 +145,7 @@ func (s *Server) completeSpeakerPairingFix(target health.Target) (string, error)
}
accountID := target.Account
if !datastore.IsSafeIdentifier(accountID) {
if !setup.IsValidAccountID(accountID) {
known, _ := s.ds.ListAccounts()
generated, genErr := setup.GenerateAccountID(known)
+4 -25
View File
@@ -1274,16 +1274,7 @@ 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.
//
// 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.
// HandleInitialSync fetches presets, recents and sources from the device and saves them to the datastore.
func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "deviceId")
if deviceID == "" {
@@ -1297,25 +1288,13 @@ func (s *Server) HandleInitialSync(w http.ResponseWriter, r *http.Request) {
return
}
confirmed := r.URL.Query().Get("confirmed") == "true"
result, err := s.sm.SyncDeviceData(deviceIP, confirmed)
if err != nil {
if err := s.sm.SyncDeviceData(deviceIP); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
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))
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}
// HandleRebootDevice reboots a device.
@@ -1,153 +0,0 @@
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))
}
}
@@ -1,125 +0,0 @@
package handlers
import (
"fmt"
"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"
)
// TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice reproduces
// https://github.com/gesellix/Bose-SoundTouch/issues/634
//
// A SoundTouch 10 had SSH enabled via the USB-stick method (rather than
// AfterTouch's own telnet-based enable-ssh flow) and, when discovered,
// reported a `margeAccountUUID` of `stick@local` instead of the usual
// 7-digit numeric Bose account ID. `handleDiscoveredDevice`
// (pkg/service/handlers/server.go) passes MargeAccountUUID straight
// through to DataStore.SaveDeviceInfo, which used to reject anything
// containing "@" as an "invalid account ID" via isSafeIdentifier's
// strict alnum-only allowlist. The device was never persisted at all.
//
// The fix widened datastore.IsSafeIdentifier to accept any device-reported
// identifier that's safe to use as a path component / XML value /
// telnet-command token, rather than requiring Bose's own 7-digit numeric
// format. setup's separate, stricter 7-digit-only IsValidAccountID was
// deleted outright in favor of calling datastore.IsSafeIdentifier directly
// everywhere an account ID needs validating — one validator, not two. So
// handleDiscoveredDevice needed no changes: it already passed
// MargeAccountUUID through unmodified, and now the datastore accepts it.
//
// What this test locks in:
//
// - A speaker reporting a non-numeric margeAccountUUID is saved
// under that account verbatim (not coerced to "default" — "default"
// remains reserved for a genuinely empty/unpaired margeAccountUUID).
//
// What this test would catch if it flipped:
//
// - If IsSafeIdentifier's allowlist regresses to reject "@" again,
// GetDeviceInfo below would error with "invalid account ID" instead
// of returning the device — the #634 symptom.
func TestIssue634_NonNumericMargeAccountUUIDDoesNotLoseDevice(t *testing.T) {
tempDir, err := os.MkdirTemp("", "issue634-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tempDir)
const deviceInfoXML = `<info deviceID="001122334455">
<name>Kitchen SoundTouch</name>
<type>SoundTouch 10</type>
<margeAccountUUID>stick@local</margeAccountUUID>
<components>
<component>
<componentCategory>SCM</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
<serialNumber>I6332527703739342000020</serialNumber>
</component>
<component>
<componentCategory>PackagedProduct</componentCategory>
<softwareVersion>27.0.6.46330.5043500 epdbuild.trunk.hepdswbld04.2022-08-04T11:20:29</softwareVersion>
<serialNumber>069231P63364828AE</serialNumber>
</component>
</components>
<margeURL>https://streaming.bose.com</margeURL>
<networkInfo type="SCM">
<macAddress>001122334455</macAddress>
<ipAddress>203.0.113.10</ipAddress>
</networkInfo>
<moduleType>sm2</moduleType>
<variant>rhino</variant>
<variantMode>normal</variantMode>
<countryCode>US</countryCode>
<regionCode>US</regionCode>
</info>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/info" {
w.Header().Set("Content-Type", "application/xml")
fmt.Fprint(w, deviceInfoXML)
} else {
http.NotFound(w, r)
}
}))
defer server.Close()
deviceIP := server.URL[len("http://"):]
ds := datastore.NewDataStore(tempDir)
sm := setup.NewManager(server.URL, ds, nil)
srv := NewServer(ds, sm, server.URL, false, false, false)
discoveredDevice := models.DiscoveredDevice{
Host: deviceIP,
Name: "Legacy Discovery Name",
ModelID: "SoundTouch 10",
SerialNo: "",
DiscoveryMethod: "UPnP",
}
t.Logf("Test scenario: /info reports non-numeric margeAccountUUID %q", "stick@local")
srv.handleDiscoveredDevice(discoveredDevice)
const (
expectedAccountID = "stick@local"
expectedDeviceID = "001122334455"
)
deviceInfo, err := ds.GetDeviceInfo(expectedAccountID, expectedDeviceID)
if err != nil {
t.Fatalf("device was not saved under account %q: %v (this is the #634 symptom — "+
"SaveDeviceInfo rejects the raw margeAccountUUID as an invalid account ID)",
expectedAccountID, err)
}
if deviceInfo.Name != "Kitchen SoundTouch" {
t.Errorf("Name = %q, want %q", deviceInfo.Name, "Kitchen SoundTouch")
}
}
+66 -206
View File
@@ -598,19 +598,7 @@ async function fetchDevices() {
if (devices.length === 0) {
container.innerHTML = "No devices known yet.";
} else {
// Built via DOM APIs rather than innerHTML/template strings: device
// fields (name, IDs, serials, ...) come from speakers and third-party
// pairing tools (see #634) and are not restricted to HTML/JS-safe
// characters, so they must never be parsed as markup or concatenated
// into inline event-handler attributes.
const table = document.createElement("table");
const headerRow = document.createElement("tr");
for (const label of ["Name & Model", "IP Address", "Device & Account ID", "Firmware & Serial", "Method", "Action"]) {
const th = document.createElement("th");
th.textContent = label;
headerRow.appendChild(th);
}
table.appendChild(headerRow);
let html = "<table><tr><th>Name & Model</th><th>IP Address</th><th>Device & Account ID</th><th>Firmware & Serial</th><th>Method</th><th>Action</th></tr>";
// Clear and repopulate selectors
const currentSyncVal = syncSelector.value;
@@ -624,83 +612,25 @@ async function fetchDevices() {
devices.forEach((d) => {
const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto";
const nameModelCell = document.createElement("td");
nameModelCell.className = "col-name-model";
const nameDiv = document.createElement("div");
nameDiv.className = "col-name";
nameDiv.textContent = d.name;
const modelDiv = document.createElement("div");
modelDiv.className = "col-model";
modelDiv.style.cssText = "font-size: 0.8em; color: #666;";
modelDiv.textContent = d.product_code;
nameModelCell.append(nameDiv, modelDiv);
const ipCell = document.createElement("td");
ipCell.className = "col-ip";
ipCell.textContent = d.ip_address;
const idsCell = document.createElement("td");
idsCell.className = "col-ids";
const deviceIdDiv = document.createElement("div");
deviceIdDiv.className = "col-deviceid";
deviceIdDiv.textContent = d.device_id;
const accountIdDiv = document.createElement("div");
accountIdDiv.className = "col-accountid";
accountIdDiv.style.cssText = "font-size: 0.8em; color: #666;";
accountIdDiv.textContent = d.account_id || "default";
idsCell.append(deviceIdDiv, accountIdDiv);
const fwCell = document.createElement("td");
fwCell.className = "col-fw-serial";
const fwDiv = document.createElement("div");
fwDiv.className = "col-firmware";
fwDiv.textContent = d.firmware_version || "0.0.0";
const serialDiv = document.createElement("div");
serialDiv.className = "col-serial";
serialDiv.style.cssText = "font-size: 0.8em; color: #666;";
serialDiv.textContent = d.device_serial_number;
fwCell.append(fwDiv, serialDiv);
const methodCell = document.createElement("td");
methodCell.className = "col-method";
methodCell.textContent = methodLabel;
const makeActionButton = (label, onClick, extra) => {
const btn = document.createElement("button");
btn.textContent = label;
btn.addEventListener("click", onClick);
if (extra) Object.assign(btn, extra);
return btn;
};
const actionCell = document.createElement("td");
actionCell.append(
makeActionButton("Inspect", () => toggleDeviceSummary(d.device_id)),
makeActionButton("Sync Data", () => prepareSync(d.device_id)),
makeActionButton("Migrate", () => prepareMigration(d.device_id)),
makeActionButton("Prime Spotify", () => primeSpotify(d.device_id), {
id: `prime-spotify-${d.device_id}`,
className: "btn-spotify",
}),
makeActionButton("Remove", () => removeDevice(d.device_id, d.name), {className: "btn-danger"}),
);
actionCell.querySelector(".btn-spotify").style.display = "none";
const row = document.createElement("tr");
row.id = `device-row-${d.device_id}`;
row.append(nameModelCell, ipCell, idsCell, fwCell, methodCell, actionCell);
const summaryRow = document.createElement("tr");
summaryRow.id = `device-summary-${d.device_id}`;
summaryRow.style.display = "none";
const summaryCell = document.createElement("td");
summaryCell.colSpan = 6;
summaryCell.id = `device-summary-cell-${d.device_id}`;
summaryCell.style.cssText = "background: #fafafa; padding: 12px;";
summaryRow.appendChild(summaryCell);
table.append(row, summaryRow);
html += `
<tr id="device-row-${d.device_id}">
<td class="col-name-model"><div class="col-name">${d.name}</div><div class="col-model" style="font-size: 0.8em; color: #666;">${d.product_code}</div></td>
<td class="col-ip">${d.ip_address}</td>
<td class="col-ids"><div class="col-deviceid">${d.device_id}</div><div class="col-accountid" style="font-size: 0.8em; color: #666;">${d.account_id || "default"}</div></td>
<td class="col-fw-serial"><div class="col-firmware">${d.firmware_version || "0.0.0"}</div><div class="col-serial" style="font-size: 0.8em; color: #666;">${d.device_serial_number}</div></td>
<td class="col-method">${methodLabel}</td>
<td>
<button onclick="toggleDeviceSummary('${d.device_id}')">Inspect</button>
<button onclick="prepareSync('${d.device_id}')">Sync Data</button>
<button onclick="prepareMigration('${d.device_id}')">Migrate</button>
<button id="prime-spotify-${d.device_id}" class="btn-spotify" style="display: none;" onclick="primeSpotify('${d.device_id}')">Prime Spotify</button>
<button class="btn-danger" onclick="removeDevice('${d.device_id}', '${d.name}')">Remove</button>
</td>
</tr>
<tr id="device-summary-${d.device_id}" style="display: none;">
<td colspan="6" id="device-summary-cell-${d.device_id}" style="background: #fafafa; padding: 12px;"></td>
</tr>
`;
const optSync = document.createElement("option");
optSync.value = d.device_id;
@@ -719,7 +649,8 @@ async function fetchDevices() {
eventSelector.appendChild(optEvent);
}
});
container.replaceChildren(table);
html += "</table>";
container.innerHTML = html;
if (currentSyncVal) syncSelector.value = currentSyncVal;
if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
@@ -876,57 +807,6 @@ 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) {
@@ -946,30 +826,14 @@ async function startSync() {
log.innerHTML = "";
try {
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) {
const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
if (response.ok) {
status.style.backgroundColor = "#dfd";
status.textContent = "✅ Sync completed successfully for " + display + "!";
results.style.display = "block";
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));
log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
} else {
const err = result ? JSON.stringify(result) : await response.text();
const err = await response.text();
throw new Error(err);
}
} catch (error) {
@@ -1060,14 +924,7 @@ async function fetchAccountList() {
const data = await response.json();
const selector = document.getElementById("account-selector");
if (selector) {
// Account IDs can contain non-alphanumeric characters (e.g.
// "stick@local", #634) — built via DOM APIs, not innerHTML.
selector.replaceChildren(...data.accounts.map(acc => {
const opt = document.createElement("option");
opt.value = acc;
opt.textContent = acc;
return opt;
}));
selector.innerHTML = data.accounts.map(acc => `<option value="${acc}">${acc}</option>`).join("");
if (data.accounts.length > 0) {
fetchAccountDetails(selector.value);
}
@@ -1092,7 +949,7 @@ async function fetchAccountDetails(accountId) {
try {
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`);
if (!response.ok) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${escapeHtml(response.statusText)}</span>`;
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${response.statusText}</span>`;
return;
}
const data = await response.json();
@@ -1107,7 +964,7 @@ async function fetchAccountDetails(accountId) {
metadataEl.innerHTML = `
${warningNotice}
<table style="width: 100%; font-size: 0.9em;">
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${escapeHtml(data.account.account_id)}</td></tr>
<tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${data.account.account_id}</td></tr>
<tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
<select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
<option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
@@ -1126,7 +983,7 @@ async function fetchAccountDetails(accountId) {
}, {});
return Object.entries(grouped).map(([pName, settings]) => `
<div style="margin-bottom: 8px;">
<strong>${escapeHtml(pName)}</strong>
<strong>${pName}</strong>
<ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
${settings.map(s => {
if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
@@ -1134,9 +991,9 @@ async function fetchAccountDetails(accountId) {
<li style="margin-bottom: 4px;">
Music Streaming Quality:
<select class="provider-setting-select"
data-account-id="${escapeHtml(data.account.account_id)}"
data-provider-id="${escapeHtml(s.provider_id)}"
data-key="${escapeHtml(s.key_name)}"
data-account-id="${data.account.account_id}"
data-provider-id="${s.provider_id}"
data-key="${s.key_name}"
style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
<option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
<option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
@@ -1146,7 +1003,7 @@ async function fetchAccountDetails(accountId) {
</li>
`;
}
return `<li>${escapeHtml(s.key_name)}: ${escapeHtml(s.value)}</li>`;
return `<li>${s.key_name}: ${s.value}</li>`;
}).join("")}
</ul>
</div>
@@ -1167,7 +1024,7 @@ async function fetchAccountDetails(accountId) {
statusEl.style.color = "#666";
}
try {
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(data.account.account_id)}/language`, {
const response = await fetch(`/api/mgmt/accounts/${data.account.account_id}/language`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1211,7 +1068,7 @@ async function fetchAccountDetails(accountId) {
}
try {
const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accID)}/provider-settings`, {
const response = await fetch(`/api/mgmt/accounts/${accID}/provider-settings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -1253,27 +1110,27 @@ async function fetchAccountDetails(accountId) {
devicesEl.innerHTML = data.devices.map(device => `
<div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
<div class="device-summary-header" data-toggle-target="device-details-${escapeHtml(device.device_id)}" style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;">
<h4 style="margin: 0">${escapeHtml(device.name || "Unnamed Device")} (${escapeHtml(device.product_code)})</h4>
<div style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;" onclick="toggleInfo('device-details-${device.device_id}')">
<h4 style="margin: 0">${device.name || "Unnamed Device"} (${device.product_code})</h4>
<div style="font-size: 0.8em; color: #666">
${escapeHtml(device.ip_address)} | ${escapeHtml(device.device_id)} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
${device.ip_address} | ${device.device_id} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
</div>
</div>
<div id="device-details-${escapeHtml(device.device_id)}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
<div id="device-details-${device.device_id}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
<div>
<h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
<div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
<strong>Serial:</strong> ${escapeHtml(device.device_serial_number || device.serial_number || "N/A")}<br>
<strong>MAC:</strong> ${escapeHtml(device.mac_address || "N/A")}<br>
<strong>Version:</strong> ${escapeHtml(device.firmware_version || "N/A")}<br>
<strong>Discovery:</strong> ${escapeHtml(device.discovery_method || "N/A")}
<strong>Serial:</strong> ${device.device_serial_number || device.serial_number || "N/A"}<br>
<strong>MAC:</strong> ${device.mac_address || "N/A"}<br>
<strong>Version:</strong> ${device.firmware_version || "N/A"}<br>
<strong>Discovery:</strong> ${device.discovery_method || "N/A"}
</div>
<h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
<ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
${device.components ? device.components.map(c => `<li><strong>${escapeHtml(c.category || c.type || 'Component')}</strong>: ${escapeHtml(c.firmware_version || 'N/A')} <br><small style="color:#777">S/N: ${escapeHtml(c.serial_number || 'N/A')}</small></li>`).join("") : "<li>No components found</li>"}
${device.components ? device.components.map(c => `<li><strong>${c.category || c.type || 'Component'}</strong>: ${c.firmware_version || 'N/A'} <br><small style="color:#777">S/N: ${c.serial_number || 'N/A'}</small></li>`).join("") : "<li>No components found</li>"}
</ul>
</div>
@@ -1293,14 +1150,14 @@ async function fetchAccountDetails(accountId) {
const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
const finalName = name || s.type || "Unknown Source";
if (finalName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${escapeHtml(finalName)}${escapeHtml(account)}</small>`;
sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${finalName}${account}</small>`;
}
}
}
return `
<div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
<strong>#${i + 1}</strong>: ${escapeHtml(itemName)}${sourceLabel}
<strong>#${i + 1}</strong>: ${itemName}${sourceLabel}
</div>
`;
}).join("")}
@@ -1318,13 +1175,13 @@ async function fetchAccountDetails(accountId) {
const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
const finalSName = sName || s.type || "Unknown Source";
if (finalSName) {
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${escapeHtml(finalSName)}${escapeHtml(account)}</small>`;
sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${finalSName}${account}</small>`;
}
}
const dateRaw = r.last_played_at || r.created_on;
const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
return `<li>${escapeHtml(name)}${sourceLabel} <br><small style="color:#888">${escapeHtml(dateStr)}</small></li>`;
return `<li>${name}${sourceLabel} <br><small style="color:#888">${dateStr}</small></li>`;
}).join("") : "<li>No recents</li>"}
</ul>
</div>
@@ -1339,8 +1196,8 @@ async function fetchAccountDetails(accountId) {
const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
return `
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${escapeHtml(s.type)}">
${escapeHtml(sourceName)}${escapeHtml(usernameSuffix)}${escapeHtml(accountSuffix)}
<span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${s.type}">
${sourceName}${usernameSuffix}${accountSuffix}
</span>
`;
}).join("") : "<small style='color:#999'>None</small>"}
@@ -1349,17 +1206,10 @@ async function fetchAccountDetails(accountId) {
</div>
</div>
`).join("");
// data-toggle-target (not an inline onclick) avoids re-embedding
// speaker-controlled device_id inside a JS-string-in-HTML-attribute
// context, which HTML-escaping alone cannot make safe.
devicesEl.querySelectorAll(".device-summary-header").forEach(el => {
el.addEventListener("click", () => toggleInfo(el.dataset.toggleTarget));
});
}
} catch (error) {
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${escapeHtml(error.message)}</span>`;
if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
console.error("Failed to fetch account details", error);
}
}
@@ -1661,6 +1511,7 @@ 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;
@@ -1916,7 +1767,7 @@ async function fetchDeviceEvents(deviceId) {
list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';
try {
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}/events`);
const response = await fetch(`/api/setup/devices/${deviceId}/events`);
const data = await response.json();
const events = data.events;
@@ -2056,7 +1907,7 @@ async function removeDevice(deviceId, name) {
}
try {
const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}`, {
const response = await fetch(`/api/setup/devices/${deviceId}`, {
method: "DELETE",
});
@@ -4953,14 +4804,14 @@ async function toggleDeviceSummary(deviceId) {
const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`);
if (!resp.ok) {
const txt = await resp.text();
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHtml(txt)}</span>`;
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHTML(txt)}</span>`;
return;
}
const data = await resp.json();
cell.innerHTML = "";
cell.appendChild(renderDeviceSummary(data));
} catch (e) {
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHtml(e.message || String(e))}</span>`;
cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHTML(e.message || String(e))}</span>`;
}
}
@@ -5165,3 +5016,12 @@ function unreachableBlock(probe) {
return wrap;
}
function escapeHTML(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+8 -8
View File
@@ -215,7 +215,7 @@ func detectOrphanDefaultEntries(ds *datastore.DataStore, paired []models.Service
if speakerAccount != info.account {
log.Printf("[Health] consistency: speaker %s reports margeAccountUUID=%s but ListAllDevices picked %s — preferring the speaker's answer for orphan-deletion suggestions",
sanitizeLog(deviceID), sanitizeLog(speakerAccount), sanitizeLog(info.account))
deviceID, speakerAccount, info.account)
}
}
@@ -289,21 +289,21 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
if speakerAccount := fetchSpeakerMargeAccount(ctx, speakerIP); speakerAccount != "" {
if speakerAccount == target.Account {
return "", fmt.Errorf("speaker %s reports margeAccountUUID=%s — refusing to delete <data-dir>/accounts/%s/devices/%s because it's the speaker's currently-active binding (re-paired since the consistency check ran?)",
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account), sanitizeLog(target.Device))
target.Device, speakerAccount, target.Account, target.Device)
}
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s confirmed margeAccountUUID=%s; target account %s is stale, proceeding with delete",
sanitizeLog(target.Device), sanitizeLog(speakerAccount), sanitizeLog(target.Account))
target.Device, speakerAccount, target.Account)
} else {
log.Printf("[Health] deleteOrphanAccountEntry: speaker %s at %s not reachable for re-confirmation; relying on operator's Confirm click",
sanitizeLog(target.Device), sanitizeLog(speakerIP))
target.Device, speakerIP)
}
} else {
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", sanitizeLog(target.Device))
log.Printf("[Health] deleteOrphanAccountEntry: no IP recorded for device %s — skipping speaker re-probe", target.Device)
}
if target.Account == accountIDDefaultPlaceholder {
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", sanitizeLog(target.Device))
log.Printf("[Health] deleteOrphanAccountEntry: deleting the \"default\" placeholder entry for device %s; this is normal after pairing completed", target.Device)
}
path := ds.AccountDeviceDir(target.Account, target.Device)
@@ -316,7 +316,7 @@ func deleteOrphanAccountEntry(ds *datastore.DataStore, target Target) (string, e
}
log.Printf("[Health] Removed orphan account entry %s (account=%s device=%s) at operator request",
path, sanitizeLog(target.Account), sanitizeLog(target.Device))
path, target.Account, target.Device)
return fmt.Sprintf("Removed stale account entry %s for device %s.", target.Account, target.Device), nil
}
@@ -479,7 +479,7 @@ func reclassifyCanonicalSourceIDs(ds *datastore.DataStore, target Target) (strin
for i := range sources {
if newID, ok := rename[sources[i].ID]; ok {
log.Printf("[Health] Re-classify %s: id %s → %s (account=%s device=%s)",
sanitizeLog(sources[i].SourceKeyType), sanitizeLog(sources[i].ID), sanitizeLog(newID), sanitizeLog(target.Account), sanitizeLog(target.Device))
sources[i].SourceKeyType, sources[i].ID, newID, target.Account, target.Device)
sources[i].ID = newID
+3 -6
View File
@@ -30,12 +30,9 @@ func suggestAccountForPairing(ds *datastore.DataStore, deviceID string) string {
return ""
}
// isSevenDigitAccountID is intentionally narrower than
// datastore.IsSafeIdentifier: it filters suggestAccountForPairing's
// candidates down to directories that look like a real Bose-issued
// account, not merely safe-to-use ones (a device-reported value like
// "stick@local", #634, is a safe identifier but not something to
// suggest as a pre-existing "real" account to reuse).
// isSevenDigitAccountID mirrors setup.IsValidAccountID without
// importing the setup package (which would pull in SSH/telnet/certmgr
// transitively — see the boundary comment near speakerInfoXML).
func isSevenDigitAccountID(s string) bool {
if len(s) != 7 {
return false
-13
View File
@@ -1,13 +0,0 @@
package health
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers (e.g. margeAccountUUID
// read live via :8090/info) may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
@@ -1,88 +0,0 @@
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)
}
}
}
+88 -131
View File
@@ -752,13 +752,6 @@ func CreateAccountDevice(ds *datastore.DataStore, account, deviceID string) (mod
device.Presets = mapPresetsToFullResponse(presets, sources)
device.Recents = mapRecentsToFullResponse(recents, sources)
if len(device.Presets) != len(presets) {
log.Printf("[Marge] /full: device %s — read %d preset(s) from disk, embedding %d after source mapping",
sanitizeLog(deviceID), len(presets), len(device.Presets))
} else {
log.Printf("[Marge] /full: device %s — embedding %d preset(s)", sanitizeLog(deviceID), len(device.Presets))
}
return device, nil
}
@@ -1508,18 +1501,19 @@ 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 {
_, 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
}
presets, err := ds.GetPresets(account, device)
if err != nil {
return err
}
presets[presetNumber-1] = models.ServicePreset{}
if presetNumber < 1 || presetNumber > len(presets) {
// Preset doesn't exist or index out of range, nothing to do
return nil
}
return presets, nil
})
presets[presetNumber-1] = models.ServicePreset{}
return err
return ds.SavePresets(account, device, presets)
}
// resolvePresetSource resolves the source a preset PUT is referencing,
@@ -1528,74 +1522,42 @@ 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.
// 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 {
func resolvePresetSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, sourceID string, presetNumber int) (*models.ConfiguredSource, []models.ConfiguredSource) {
for i := range sources {
if sources[i].ID == sourceID {
return &sources[i]
return &sources[i], sources
}
}
// 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]
return &sources[i], sources
}
}
}
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.
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))
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))
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
}
if src := findConfiguredSource(updated, sourceID); src != nil {
return src, updated
}
updated = append(updated, canonical)
return &updated[len(updated)-1], updated
return nil, sources
}
// UpdatePreset updates or creates a preset for the specified account and device.
@@ -1605,6 +1567,11 @@ 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"`
@@ -1670,19 +1637,14 @@ func UpdatePreset(ds *datastore.DataStore, account, device string, presetNumber
Username: newPresetElem.Name,
}
// 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{})
}
// Ensure presets list is large enough
for len(presets) < presetNumber {
presets = append(presets, models.ServicePreset{})
}
presets[presetNumber-1] = presetObj
presets[presetNumber-1] = presetObj
return presets, nil
}); err != nil {
if err = ds.SavePresets(account, device, presets); err != nil {
return nil, err
}
@@ -1811,6 +1773,11 @@ 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
@@ -1855,20 +1822,9 @@ 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)
// 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 {
if err := ds.SaveRecents(account, device, recents); err != nil {
return nil, err
}
@@ -1896,7 +1852,7 @@ func learnSource(ds *datastore.DataStore, account, device string, sources []mode
matchingSrc.SecretType = constants.CredentialTypeToken
}
persistLearnedSource(ds, account, device, matchingSrc)
persistLearnedSource(ds, account, device, sources, matchingSrc)
}
return matchingSrc, sourceLearned
@@ -2046,24 +2002,26 @@ func updateSourceFields(src *models.ConfiguredSource, credentialValue, sourceNam
return learned
}
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
func persistLearnedSource(ds *datastore.DataStore, account, device string, sources []models.ConfiguredSource, matchingSrc *models.ConfiguredSource) {
updatedSources := make([]models.ConfiguredSource, len(sources))
copy(updatedSources, sources)
return sources, nil
}
found := false
for i := range updatedSources {
if updatedSources[i].ID == matchingSrc.ID {
updatedSources[i] = *matchingSrc
found = true
break
}
}
return append(sources, *matchingSrc), nil
})
if err != nil {
if !found {
updatedSources = append(updatedSources, *matchingSrc)
}
if err := ds.SaveConfiguredSources(account, device, updatedSources); err != nil {
log.Printf("[MARGE_ERR] Failed to persist learned source for %s: %s", sanitizeLog(device), sanitizeErr(err))
}
}
@@ -2449,6 +2407,7 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
}
devID := entry.Name()
sources, _ := ds.GetConfiguredSources(account, devID)
newSrc := models.ConfiguredSource{
ID: sourceID,
@@ -2478,39 +2437,37 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
PrepareConfiguredSource(&newSrc)
// 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
}
// 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
if sameProvider ||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
sources[i] = newSrc
return sources, nil
}
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
}
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 sameProvider ||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
sources[i] = newSrc
replaced = true
break
}
}
if !replaced {
sources = append(sources, newSrc)
}
_ = ds.SaveConfiguredSources(account, devID, sources)
}
return sourceID, nil
+3 -5
View File
@@ -5,8 +5,6 @@ import (
"errors"
"fmt"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// InitPlan describes everything required to take a factory-reset (or
@@ -252,8 +250,8 @@ func (m *Manager) runURLRewrite(plan InitPlan, emit func(StepKind, string, StepS
// ID, or validating a user-supplied value.
func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func(StepKind, string, StepStatus, error)) (InitPlan, error) {
if plan.AccountID != "" {
if !datastore.IsSafeIdentifier(plan.AccountID) {
invalidErr := fmt.Errorf("invalid AccountID %q: must be a non-empty, path-safe identifier", plan.AccountID)
if !IsValidAccountID(plan.AccountID) {
invalidErr := fmt.Errorf("invalid AccountID %q: must be exactly 7 digits", plan.AccountID)
emit(StepGenerateAccountID, "validate account ID", StatusFailed, invalidErr)
return plan, invalidErr
@@ -262,7 +260,7 @@ func (m *Manager) resolveAccountID(plan InitPlan, info *DeviceInfoXML, emit func
return plan, nil
}
if info.MargeAccountUUID != "" && datastore.IsSafeIdentifier(info.MargeAccountUUID) {
if info.MargeAccountUUID != "" && IsValidAccountID(info.MargeAccountUUID) {
plan.AccountID = info.MargeAccountUUID
emit(StepGenerateAccountID, "reuse existing margeAccountUUID="+plan.AccountID, StatusOK, nil)
+7 -11
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// fakeSession is a StateMachine that records the order of
@@ -197,13 +195,11 @@ func TestExecuteInitPlan_ReusesExistingAccountUUID(t *testing.T) {
}
func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
// Devices that report an unsafe/malformed UUID (e.g. containing a path
// separator) must not be reused — we treat them as factory-reset for ID
// purposes. A merely non-numeric UUID (e.g. "stick@local", #634) IS
// reused now; see resolveAccountID/datastore.IsSafeIdentifier.
// Devices that report a non-7-digit UUID (e.g. a stale local value) must
// not be reused — we treat them as factory-reset for ID purposes.
info := &fakeInfoResponder{
deviceID: "AABBCCDDEEFF",
paired: "not/valid",
paired: "not-7-digits",
postInitPaired: "", // we'll learn the generated ID from the result
}
sess := &fakeSession{}
@@ -224,11 +220,11 @@ func TestExecuteInitPlan_GeneratesAccountWhenDeviceUUIDInvalid(t *testing.T) {
t.Fatalf("ExecuteInitPlan: %v", err)
}
if !datastore.IsSafeIdentifier(got.AccountID) {
t.Errorf("got.AccountID = %q, want a valid generated ID", got.AccountID)
if !IsValidAccountID(got.AccountID) {
t.Errorf("got.AccountID = %q, want a valid 7-digit ID", got.AccountID)
}
if got.AccountID == "not/valid" {
if got.AccountID == "not-7-digits" {
t.Error("orchestrator should not reuse an invalid UUID")
}
}
@@ -240,7 +236,7 @@ func TestExecuteInitPlan_RejectsInvalidSuppliedAccountID(t *testing.T) {
plan := InitPlan{
DeviceIP: "192.0.2.10",
AccountID: "abc/def",
AccountID: "abc",
SkipURLRewrite: true,
}
@@ -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, false); err != nil {
if err := m.SyncDeviceData(deviceIP); err != nil {
t.Fatalf("SyncDeviceData: %v", err)
}
+22 -20
View File
@@ -1,7 +1,6 @@
package setup
import (
"bytes"
"crypto/rand"
"encoding/xml"
"errors"
@@ -12,8 +11,6 @@ import (
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// PairAccountTimeouts bounds every step of the pairing call so a wedged
@@ -47,8 +44,8 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
logs strings.Builder
)
if !datastore.IsSafeIdentifier(accountID) {
return result, "", fmt.Errorf("invalid account ID %q: must be a non-empty, path-safe identifier", accountID)
if !IsValidAccountID(accountID) {
return result, "", fmt.Errorf("invalid account ID %q: must be exactly 7 digits", accountID)
}
supported, supportedErr := m.probeSetMargeAccount(deviceIP)
@@ -87,9 +84,6 @@ func (m *Manager) PairAccount(deviceIP, accountID string, t TelnetClient) (PairA
result.TelnetAttempted = true
// Safe to concatenate: datastore.IsSafeIdentifier (checked above) rejects
// any whitespace or control characters, so accountID can't smuggle extra
// tokens into this single-line telnet command.
cmd := "envswitch accountid set " + accountID
resp, err := t.SendCommand(cmd)
@@ -141,8 +135,8 @@ func (m *Manager) EnsureMargeAccountPaired(deviceIP, wantAccountID string, t Tel
}
target = generated
} else if !datastore.IsSafeIdentifier(target) {
return "", false, "", fmt.Errorf("invalid account id %q: must be a non-empty, path-safe identifier", target)
} else if !IsValidAccountID(target) {
return "", false, "", fmt.Errorf("invalid account id %q: must be exactly 7 digits", target)
}
_, pairLogs, pairErr := m.PairAccount(deviceIP, target, t)
@@ -202,18 +196,9 @@ func (m *Manager) probeSetMargeAccount(deviceIP string) (bool, error) {
func (m *Manager) postSetMargeAccount(deviceIP, accountID string) error {
url := buildDeviceURL(deviceIP, "/setMargeAccount")
// accountID is XML-escaped rather than interpolated raw:
// datastore.IsSafeIdentifier already excludes '<', '>', '&', '\'', '"'
// (see #634), but escaping here too means this stays well-formed even
// if that gate is ever bypassed.
var escapedAccountID bytes.Buffer
if err := xml.EscapeText(&escapedAccountID, []byte(accountID)); err != nil {
return fmt.Errorf("escape account ID: %w", err)
}
body := fmt.Sprintf(
`<PairDeviceWithAccount><accountId>%s</accountId><userAuthToken>aftertouch</userAuthToken></PairDeviceWithAccount>`,
escapedAccountID.String(),
accountID,
)
client := &http.Client{
@@ -327,6 +312,23 @@ func buildDeviceURL(deviceIP, path string) string {
return "http://" + deviceIP + ":8090" + path
}
// IsValidAccountID reports whether s is a syntactically valid SoundTouch
// account ID — exactly 7 numeric digits, the format used by every
// Bose-cloud-issued ID we have observed in captures.
func IsValidAccountID(s string) bool {
if len(s) != 7 {
return false
}
for _, ch := range s {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
// GenerateAccountID returns a fresh 7-digit account ID that does not collide
// with any value in known. It uses crypto/rand and re-rolls on collision.
func GenerateAccountID(known []string) (string, error) {
+25 -8
View File
@@ -10,8 +10,6 @@ import (
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// fakeDevice spins up an httptest.Server that pretends to be the SoundTouch
@@ -315,7 +313,7 @@ func TestEnsureMargeAccountPaired_UnpairedGeneratesAndPairs(t *testing.T) {
t.Error("alreadyPaired should be false for an unpaired device")
}
if !datastore.IsSafeIdentifier(accountID) {
if !IsValidAccountID(accountID) {
t.Errorf("accountID %q is not a valid generated ID", accountID)
}
@@ -354,7 +352,7 @@ func TestEnsureMargeAccountPaired_RejectsInvalidWantAccountID(t *testing.T) {
m := NewManager("", nil, nil)
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not/valid", nil)
_, _, _, err := m.EnsureMargeAccountPaired(d.addr, "not-7-digits", nil)
if err == nil {
t.Fatal("expected an error for an invalid --account value")
}
@@ -477,9 +475,28 @@ func TestPreflightInitPlan_UnrecognisedStatusFailsClosed(t *testing.T) {
}
}
// Account-ID format validation is now solely datastore.IsSafeIdentifier's
// responsibility (see datastore.TestIsSafeIdentifier); setup no longer has
// its own account-ID validator to test.
func TestIsValidAccountID(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"1234567", true},
{"0000000", true},
{"9999999", true},
{"", false},
{"123456", false},
{"12345678", false},
{"123456a", false},
{"-123456", false},
{" 123456", false},
}
for _, tc := range cases {
if got := IsValidAccountID(tc.in); got != tc.want {
t.Errorf("IsValidAccountID(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
id, err := GenerateAccountID(nil)
@@ -487,7 +504,7 @@ func TestGenerateAccountID_AvoidsCollisions(t *testing.T) {
t.Fatalf("GenerateAccountID(nil): %v", err)
}
if !datastore.IsSafeIdentifier(id) {
if !IsValidAccountID(id) {
t.Errorf("generated ID %q is not valid", id)
}
+57 -249
View File
@@ -2635,51 +2635,12 @@ func (m *Manager) resolveIP(host string, client SSHClient) (string, error) {
ErrResolvedFromServiceOnly, host, resolved)
}
// 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) {
// SyncDeviceData fetches presets, recents and sources from the device and saves them to the datastore.
func (m *Manager) SyncDeviceData(deviceIP string) error {
// 1. Fetch info to get Serial Number (account identifier)
info, err := m.GetLiveDeviceInfo(deviceIP)
if err != nil {
return SyncResult{}, fmt.Errorf("failed to get device info: %w", err)
return fmt.Errorf("failed to get device info: %w", err)
}
log.Printf("Starting sync for device at %s: Name='%s', DeviceID='%s', SerialNumber='%s'",
@@ -2691,7 +2652,7 @@ func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, e
deviceID := info.DeviceID
if deviceID == "" {
log.Printf("No deviceID found in /info response for device '%s' at %s", sanitizeLog(info.Name), sanitizeLog(deviceIP))
return SyncResult{}, fmt.Errorf("no deviceID found in /info response for device at %s - cannot sync without canonical device identifier", deviceIP)
return 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))
@@ -2715,42 +2676,14 @@ func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, e
accountID = "default"
}
// 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)
}
// 2. Fetch Presets from :8090
m.syncPresets(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)
}
// 3. Fetch Recents from :8090
m.syncRecents(deviceIP, accountID, deviceID)
// 4. Fetch Sources
result.SourcesCount = m.syncSources(deviceIP, accountID, deviceID)
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
@@ -2764,113 +2697,28 @@ func (m *Manager) SyncDeviceData(deviceIP string, confirmed bool) (SyncResult, e
// 6. Create off-device backup of system configuration
_ = m.BackupConfigOffDevice(deviceIP)
result.Applied = true
return result, nil
return nil
}
// 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) {
func (m *Manager) syncPresets(deviceIP, accountID, deviceID string) {
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 {
return nil, err
log.Printf("[SYNC_ERR] Failed to fetch presets for %s: %v", sanitizeLog(deviceIP), err)
return
}
defer func() { _ = resp.Body.Close() }()
var ps models.Presets
if decodeErr := xml.NewDecoder(resp.Body).Decode(&ps); decodeErr != nil {
return nil, decodeErr
return
}
var servicePresets []models.ServicePreset
@@ -2914,28 +2762,10 @@ func (m *Manager) fetchLivePresets(deviceIP string) ([]models.ServicePreset, err
})
}
return servicePresets, nil
_ = m.DataStore.SavePresets(accountID, deviceID, servicePresets)
}
// 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) {
func (m *Manager) syncRecents(deviceIP, accountID, deviceID string) {
recentsURL := fmt.Sprintf("http://%s:8090/recents", deviceIP)
if _, _, splitErr := net.SplitHostPort(deviceIP); splitErr == nil {
recentsURL = fmt.Sprintf("http://%s/recents", deviceIP)
@@ -2943,14 +2773,14 @@ func (m *Manager) fetchLiveRecents(deviceIP string) ([]models.ServiceRecent, err
resp, err := m.HTTPGet(recentsURL)
if err != nil {
return nil, err
return
}
defer func() { _ = resp.Body.Close() }()
var rr models.RecentsResponse
if decodeErr := xml.NewDecoder(resp.Body).Decode(&rr); decodeErr != nil {
return nil, decodeErr
return
}
var serviceRecents []models.ServiceRecent
@@ -2977,28 +2807,10 @@ func (m *Manager) fetchLiveRecents(deviceIP string) ([]models.ServiceRecent, err
})
}
return serviceRecents, nil
_ = m.DataStore.SaveRecents(accountID, deviceID, serviceRecents)
}
// 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 {
func (m *Manager) syncSources(deviceIP, accountID, deviceID string) {
client := m.NewSSH(deviceIP)
sourcesXML, err := client.Run("cat /mnt/nv/BoseApp-Persistence/1/Sources.xml")
@@ -3023,7 +2835,7 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) int {
_ = m.DataStore.SaveConfiguredSources(accountID, deviceID, srs.Sources)
return len(srs.Sources)
return
}
}
@@ -3035,50 +2847,46 @@ func (m *Manager) syncSources(deviceIP, accountID, deviceID string) int {
resp, err := m.HTTPGet(sourcesURL)
if err != nil {
return -1
return
}
defer func() { _ = resp.Body.Close() }()
var srs models.Sources
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr != nil {
return -1
if decodeErr := xml.NewDecoder(resp.Body).Decode(&srs); decodeErr == nil {
var configuredSources []models.ConfiguredSource
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
Secret: "",
SecretType: "",
}
if s.Status == "READY" {
cs.SecretType = "token"
}
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)
}
// 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)
}
var configuredSources []models.ConfiguredSource
for _, s := range srs.SourceItem {
cs := models.ConfiguredSource{
DisplayName: s.DisplayName,
Secret: "",
SecretType: "",
}
if s.Status == "READY" {
cs.SecretType = "token"
}
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)
}
// 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
@@ -1,142 +0,0 @@
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))
}
}
+3 -3
View File
@@ -64,7 +64,7 @@ func TestSyncDeviceData_UsesDeviceID(t *testing.T) {
manager := NewManager("http://localhost:8000", ds, cm)
// Test SyncDeviceData
_, err := manager.SyncDeviceData(serverHost, false)
err := manager.SyncDeviceData(serverHost)
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, false)
err := manager.SyncDeviceData(serverHost)
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, false)
err := manager.SyncDeviceData(serverHost)
if err != nil {
t.Fatalf("SyncDeviceData failed: %v", err)
}
+1 -1
View File
@@ -279,7 +279,7 @@ func TestSyncSources_Format(t *testing.T) {
deviceIP := mockDevice.Listener.Addr().String()
accountID := "1234567"
deviceID := "001122334455"
_, err = m.SyncDeviceData(deviceIP, false)
err = m.SyncDeviceData(deviceIP)
if err != nil {
t.Fatalf("SyncDeviceData failed: %v", err)
}
+98
View File
@@ -0,0 +1,98 @@
package soundtouchweb
import (
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// autoResumeBackoff is the delay before re-issuing a dropped content item,
// giving a transient upstream hiccup a moment to clear before retrying.
const autoResumeBackoff = 2 * time.Second
// autoResumeState tracks what ConnectDeviceWebSocket needs to decide whether
// a now_playing transition should trigger an auto-resume. Split out from the
// WebSocket goroutine so the decision can be unit tested without a live
// connection.
//
// resumeAttempts only labels log lines — it is never used to cap retries.
// A resume is gated on wasError being false (see observe), which already
// means at most one attempt ever fires per drop: if the attempt fails and
// the source stays in error, every following event has wasError=true and
// nothing fires again until a genuine recovery is observed. A station that
// keeps recovering and re-dropping (the reported #622 pattern — a TuneIn
// stream disconnecting the speaker on a fixed cycle, indefinitely, while
// otherwise healthy) is exactly the case this should keep resuming forever.
type autoResumeState struct {
lastGoodContentItem *models.ContentItem
resumeAttempts int
}
// observe updates the state for a new now_playing event and reports whether
// the caller should fire an auto-resume for item, plus a label for the log
// line. prevSource is the source seen on the previous event.
//
// #622: some TuneIn stations disconnect the speaker's audio pipeline on
// their own (errorUpdate 1041 SOURCE_DISCONNECTED, observed ~5m35s into
// playback on one reporter's setup) even though the SoundTouch WebSocket
// control channel stays healthy throughout. The firmware does not recover
// on its own, so a fresh transition into an error source right after a
// healthy one — the speaker dropping a source it didn't choose to leave, as
// opposed to the user picking a new one — re-issues the last content item,
// exactly what pressing the physical preset button again does.
func (s *autoResumeState) observe(prevSource string, np *models.NowPlaying) (item *models.ContentItem, attempt int, shouldResume bool) {
wasError := isErrorSource(prevSource)
nowError := isErrorSource(np.Source)
if !nowError {
if np.ContentItem != nil {
s.lastGoodContentItem = np.ContentItem
}
return nil, 0, false
}
if wasError || s.lastGoodContentItem == nil {
return nil, 0, false
}
s.resumeAttempts++
return s.lastGoodContentItem, s.resumeAttempts, true
}
// autoResumePlayback re-selects item on conn's device after autoResumeBackoff.
// It runs in its own goroutine (never on the WebSocket read loop) so a slow
// or hanging /select call can't stall processing of further device events.
func autoResumePlayback(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int) {
autoResumePlaybackAfter(conn, deviceID, item, attempt, autoResumeBackoff)
}
// autoResumePlaybackAfter is autoResumePlayback with an injectable delay so
// tests don't have to wait out the real backoff.
func autoResumePlaybackAfter(conn *webtypes.DeviceConnection, deviceID string, item *models.ContentItem, attempt int, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-timer.C:
case <-conn.Done():
return
}
if conn.Client == nil {
return
}
if err := conn.Client.SelectContentItem(item); err != nil {
log.Printf("[play] device=%q auto-resume attempt %d failed: %v",
sanitizeLog(deviceID), attempt, err)
return
}
log.Printf("[play] device=%q auto-resume attempt %d re-selected source=%q location=%q",
sanitizeLog(deviceID), attempt, sanitizeLog(item.Source), sanitizeLog(item.Location))
}
@@ -0,0 +1,190 @@
package soundtouchweb
import (
"strings"
"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 tuneInNowPlaying(source string) *models.NowPlaying {
return &models.NowPlaying{
Source: source,
ContentItem: &models.ContentItem{
Source: "TUNEIN",
Type: "stationurl",
Location: "/v1/playback/station/s119025",
ItemName: "Arabella Lovesongs",
},
}
}
func TestAutoResumeState_HealthyRemembersContentItemAndDoesNotResume(t *testing.T) {
s := &autoResumeState{}
item, attempt, shouldResume := s.observe("", tuneInNowPlaying("TUNEIN"))
if shouldResume {
t.Fatalf("shouldResume = true on a healthy source, want false")
}
if item != nil || attempt != 0 {
t.Errorf("item/attempt = %v/%d, want nil/0", item, attempt)
}
if s.lastGoodContentItem == nil {
t.Fatal("lastGoodContentItem was not recorded from a healthy now_playing")
}
}
func TestAutoResumeState_FreshErrorAfterHealthyTriggersResume(t *testing.T) {
s := &autoResumeState{}
// Prime with a healthy TUNEIN event, matching the WS handler calling
// observe once per event with the source seen on the previous call.
s.observe("", tuneInNowPlaying("TUNEIN"))
item, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatal("shouldResume = false on a fresh error transition, want true")
}
if attempt != 1 {
t.Errorf("attempt = %d, want 1", attempt)
}
if item == nil || item.Location != "/v1/playback/station/s119025" {
t.Errorf("item = %+v, want the last healthy ContentItem", item)
}
}
func TestAutoResumeState_DoesNotResumeWithoutAPriorGoodContentItem(t *testing.T) {
s := &autoResumeState{}
// No healthy event was ever observed, so there's nothing to restore.
_, _, shouldResume := s.observe("", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatal("shouldResume = true with no prior good ContentItem, want false")
}
}
func TestAutoResumeState_DoesNotResumeOnRepeatedErrorEvents(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE")) // first resume, attempt 1
// A second consecutive error event (wasError=true this time) must not
// fire another resume — one attempt per drop, not per event.
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatal("shouldResume = true on a repeated error event, want false")
}
}
func TestAutoResumeState_KeepsResumingIndefinitelyAcrossRepeatedDrops(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
// The reported #622 pattern: the same station drops and (once resumed)
// recovers repeatedly, indefinitely, on a fixed cycle. Each fresh drop
// after a genuine recovery must keep resuming — there is no cap.
const cycles = 20
for i := 1; i <= cycles; i++ {
_, attempt, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatalf("cycle %d: shouldResume = false, want true", i)
}
if attempt != i {
t.Errorf("cycle %d: attempt label = %d, want %d", i, attempt, i)
}
s.observe("INVALID_SOURCE", tuneInNowPlaying("TUNEIN")) // the resume worked
}
}
func TestAutoResumeState_StopsRetryingAfterAFailedResume(t *testing.T) {
s := &autoResumeState{}
s.observe("", tuneInNowPlaying("TUNEIN"))
_, _, shouldResume := s.observe("TUNEIN", tuneInNowPlaying("INVALID_SOURCE"))
if !shouldResume {
t.Fatal("shouldResume = false on the first drop, want true")
}
// The resume attempt itself failed (or the station is genuinely gone):
// the speaker keeps reporting the same error source on further events.
// wasError is now true, so nothing should fire again without a genuine
// recovery in between — this is what keeps a truly dead station from
// being retried forever.
for i := 0; i < 5; i++ {
_, _, shouldResume := s.observe("INVALID_SOURCE", tuneInNowPlaying("INVALID_SOURCE"))
if shouldResume {
t.Fatalf("iteration %d: shouldResume = true on a persisting error, want false", i)
}
}
}
func TestAutoResumePlaybackAfter_ReselectsContentItem(t *testing.T) {
speaker, captured := setupSpeakerMock(t, nil)
defer speaker.Close()
c := client.NewClient(&client.Config{Host: speaker.URL})
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025", ItemName: "Arabella Lovesongs"}
done := make(chan struct{})
go func() {
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, 0)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("autoResumePlaybackAfter did not return in time")
}
body, ok := captured["/select"]
if !ok {
t.Fatalf("no /select request captured; requests: %v", captured)
}
if !strings.Contains(body, `source="TUNEIN"`) || !strings.Contains(body, "/v1/playback/station/s119025") {
t.Errorf("/select body = %q, want it to carry the TUNEIN content item", body)
}
}
func TestAutoResumePlaybackAfter_StopsWhenConnectionClosed(t *testing.T) {
speaker, captured := setupSpeakerMock(t, nil)
defer speaker.Close()
c := client.NewClient(&client.Config{Host: speaker.URL})
conn := webtypes.NewDeviceConnection(c, &models.DeviceInfo{DeviceID: "DEVICEID01"})
conn.Close()
item := &models.ContentItem{Source: "TUNEIN", Type: "stationurl", Location: "/v1/playback/station/s119025"}
done := make(chan struct{})
go func() {
autoResumePlaybackAfter(conn, "DEVICEID01", item, 1, time.Hour)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("autoResumePlaybackAfter did not return promptly after conn.Close()")
}
if _, ok := captured["/select"]; ok {
t.Error("/select was called after the connection was closed, want no request")
}
}
@@ -1,137 +0,0 @@
//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")
}
}
@@ -1,290 +0,0 @@
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
}
@@ -1,249 +0,0 @@
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)
}
}
+11 -216
View File
@@ -3,7 +3,6 @@ package soundtouchweb
import (
"context"
"log"
"strings"
"sync"
"time"
@@ -11,18 +10,12 @@ 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.
// 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 {
func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryService {
cfg, err := config.LoadFromEnv()
if err != nil {
log.Printf("Failed to load config: %v, using defaults", err)
@@ -37,23 +30,6 @@ func NewDiscoveryService(discoveryInterface string, configuredHosts ...string) *
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)
}
@@ -64,13 +40,9 @@ func NewDiscoveryService(discoveryInterface string, configuredHosts ...string) *
// 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 nil
return
}
c := client.NewClient(&client.Config{
@@ -82,7 +54,7 @@ func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtyp
info, err := c.GetDeviceInfo()
if err != nil {
log.Printf("Failed to fetch device info from %s (%s): %v", sanitizeLog(host), sanitizeLog(source), err)
return nil
return
}
// Ensure IPAddress is set for the web UI
@@ -95,7 +67,7 @@ func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtyp
// Lost a race — another goroutine inserted the same host
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
// on the existing entry; discard our conn.
return nil
return
}
go app.UpdateDeviceStatus(host, conn)
@@ -118,13 +90,10 @@ func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtyp
}()
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, and prunes any previously-seeded host that no
// longer appears in that set. Idempotent: already-known hosts are skipped.
// (if set) via AddDeviceByHost. 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.
//
@@ -133,56 +102,15 @@ func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtyp
// 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 nil, 0, nil, nil
return
}
app.seedMu.Lock()
defer app.seedMu.Unlock()
var wg sync.WaitGroup
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 {
for _, host := range app.ExtraDeviceHosts() {
if host == "" {
continue
}
@@ -191,129 +119,11 @@ func (app *WebApp) seedExtraDevices() (inserted []seededExtraDevice, removed int
go func(h string) {
defer wg.Done()
conn := app.addDeviceByHost(h, 8090, "service-store")
if conn == nil {
return
}
mu.Lock()
inserted = append(inserted, seededExtraDevice{host: h, conn: conn})
mu.Unlock()
app.AddDeviceByHost(h, 8090, "service-store")
}(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
@@ -354,21 +164,6 @@ 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, classifySource(device.DiscoveryMethod))
app.AddDeviceByHost(device.Host, device.Port, "discovered")
}
}
// 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"
}
-324
View File
@@ -1,324 +0,0 @@
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")
}
+41 -95
View File
@@ -62,12 +62,7 @@ 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.
//
// 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)
ExtraDeviceHosts func() []string
// TriggerDiscovery, when set, runs an external discovery sweep instead of
// this app's own mDNS/UPnP. The embedded build wires it to the host
@@ -83,10 +78,14 @@ 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
// AutoResumeOnSourceDisconnect, when set and returning true, makes
// ConnectDeviceWebSocket re-issue a device's last playing content item
// after an unsolicited drop into an error source (#622). Opt-in: the
// embedded build wires it to Settings.AutoResumeOnSourceDisconnect
// (settings.json, default false); standalone soundtouch-player leaves it
// nil, which disables the behaviour. Read once per drop rather than
// cached, so toggling the setting takes effect without a restart.
AutoResumeOnSourceDisconnect func() bool
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
@@ -114,12 +113,11 @@ func (app *WebApp) proxyServiceURL() string {
return app.ServiceURL
}
// DeviceEntry pairs a device id with its connection and the LastSeen value
// captured by DeviceSnapshot under the registry lock.
// DeviceEntry pairs a device id with its connection. Used by
// DeviceSnapshot so callers can iterate without holding the lock.
type DeviceEntry struct {
ID string
Device *webtypes.DeviceConnection
LastSeen time.Time
ID string
Device *webtypes.DeviceConnection
}
// NewWebApp creates a new WebApp instance for SPA mode
@@ -143,9 +141,9 @@ func (app *WebApp) GetDevice(id string) (*webtypes.DeviceConnection, bool) {
return device, ok
}
// 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
// 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
// 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.
@@ -155,11 +153,7 @@ 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,
LastSeen: device.LastSeen,
})
out = append(out, DeviceEntry{ID: id, Device: device})
}
return out
@@ -229,36 +223,25 @@ 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: app.deviceViewSnapshot(),
Data: devices,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
@@ -728,9 +711,20 @@ 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: app.deviceViewSnapshot(),
Data: devices,
}
// Send to all connected clients
@@ -1099,54 +1093,6 @@ 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")
-75
View File
@@ -893,78 +893,3 @@ 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())
}
}
-1
View File
@@ -86,7 +86,6 @@ 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,8 +399,6 @@ 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%;
+3 -25
View File
@@ -5,31 +5,6 @@
<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": {
@@ -39,6 +14,9 @@
}
}
</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,7 +20,6 @@ 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' }),
+2 -12
View File
@@ -102,6 +102,7 @@ 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') {
@@ -115,11 +116,7 @@ function App() {
}
} else if (msg.type === 'status_update' && msg.deviceId) {
setDevices(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;
if (!prev[msg.deviceId]) return prev;
return {
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
@@ -138,13 +135,6 @@ 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,7 +23,6 @@ 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';
@@ -34,19 +33,14 @@ 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>
${!stereoPair ? html`<button class="device-remove" title="Remove this device"
<button class="device-remove" title="Remove this device"
aria-label="Remove this device"
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}></button>` : null}
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}></button>
</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,27 +23,10 @@ export function Library({ devices }) {
// invalidate the current selection.
useEffect(() => {
const entries = Object.entries(devices);
if (deviceId && devices[deviceId]) return;
if (entries.length === 0) {
if (deviceId) setDeviceId(null);
return;
if (!deviceId && entries.length > 0) {
setDeviceId(entries[0][0]);
}
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]);
}, [devices]);
// Reload registered servers whenever deviceId changes.
useEffect(() => {
@@ -7,17 +7,13 @@ 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() {
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));
api.zone(deviceId).then(resp => {
if (resp.success) setZone(resp.data);
}).finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, [deviceId]);
@@ -52,15 +48,11 @@ export function Zone({ deviceId, devices }) {
if (!zone) return null;
// 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.
// Devices not already in the zone are available to add
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
const available = Object.entries(candidates).filter(([ip]) => !zoneIps.has(ip));
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
const deviceName = (ip) => devices[ip]?.info?.name || candidates[ip]?.info?.name || ip;
const deviceName = (ip) => devices[ip]?.info?.name || ip;
return html`
<div class="zone-section">
@@ -1,15 +0,0 @@
# 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.
@@ -1,10 +0,0 @@
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.
@@ -1,202 +0,0 @@
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.
@@ -1,49 +0,0 @@
{
"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
}
}
}
}
}
@@ -1,21 +0,0 @@
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
@@ -1,114 +0,0 @@
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`)
}
}
+43 -69
View File
@@ -46,10 +46,23 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
// Send initial device list
if err := conn.WriteJSON(webtypes.WebSocketMessage{
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{
Type: "devices",
Data: app.deviceViewSnapshot(),
}); err != nil {
Data: devices,
}
if err := conn.WriteJSON(initialMessage); err != nil {
log.Printf("Failed to send initial data: %v", err)
return
}
@@ -87,39 +100,25 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
for _, message := range app.periodicPlayerMessages() {
if err := conn.WriteJSON(message); err != nil {
log.Printf("Failed to send device update: %v", err)
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
}
}
}
}
}
// 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 {
@@ -167,6 +166,12 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
// error source is logged once per transition into it, not on every event.
var prevSource string
// resumeState survives both the speaker's own WebSocket reconnects and
// this loop's outer reconnects (declared once, outside the loop) so an
// auto-resume can fire regardless of which layer last re-established
// the connection.
resumeState := &autoResumeState{}
for {
// Stop if the device was removed from the registry (conn.Close()).
select {
@@ -190,6 +195,11 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
logNowPlayingError(deviceID, np.Source, np.SourceAccount)
}
if item, attempt, shouldResume := resumeState.observe(prevSource, np); shouldResume &&
app.AutoResumeOnSourceDisconnect != nil && app.AutoResumeOnSourceDisconnect() {
go autoResumePlayback(conn, deviceID, item, attempt)
}
prevSource = np.Source
conn.UpdateStatus(func(s *webtypes.DeviceStatus) {
@@ -219,10 +229,6 @@ 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)
@@ -303,16 +309,6 @@ 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".
@@ -322,15 +318,6 @@ 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.
@@ -362,24 +349,11 @@ 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: 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.
// Mark as connected if we successfully got at least one
// status from this round. Mirrors prior behaviour.
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
-250
View File
@@ -1,250 +0,0 @@
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,11 +3,9 @@
package webtypes
import (
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -77,7 +75,6 @@ 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,
})
@@ -95,120 +92,11 @@ 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
+5 -66
View File
@@ -47,12 +47,6 @@ 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
@@ -68,7 +62,6 @@ 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"`
}
@@ -92,9 +85,10 @@ 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 and must not be
// mutated. Use UpdateStatus or SetStatus to apply changes. Never returns
// nil for connections built via NewDeviceConnection.
// 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.
func (c *DeviceConnection) Status() *DeviceStatus {
return c.status.Load()
}
@@ -134,7 +128,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, Group)
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass)
// 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
@@ -153,61 +147,6 @@ 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"`
+3 -32
View File
@@ -21,11 +21,7 @@ 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.
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.
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).
### Logs
@@ -181,39 +177,14 @@ 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, gzip-compressed as `.backup.gz` (plain `.backup`, uncompressed, if
> `gzip` wasn't available on your device):
> **Tip — rollback:** if the new binary misbehaves, the installer left a `.backup` file alongside it:
> ```bash
> 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:
> ls /mnt/nv/aftertouch/aftertouch-service*.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.
+2 -103
View File
@@ -97,7 +97,6 @@ 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
@@ -106,86 +105,6 @@ 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" \
@@ -195,34 +114,15 @@ 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" ] && [ "$SKIP_BACKUP" != "yes" ]; then
if [ -f "$INSTALL_DIR/aftertouch-service" ]; 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"
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
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
echo "Backed up current binary ($current_version) → $BACKUP_FILE"
fi
@@ -236,7 +136,6 @@ 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
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# Emits a "Quick downloads" markdown section with real, direct download
# links for soundtouch-service and soundtouch-cli, one row per platform.
# Asset URLs are deterministic (<binary>-<tag>-<os>-<arch>[.exe]), so this
# needs no GitHub API call to build them.
#
# Usage: quick-downloads.sh <tag-name> <owner/repo>
# Output goes to stdout, wrapped in <!-- quick-downloads:start/end -->
# markers so callers can find-and-replace a previously inserted block.
set -euo pipefail
TAG_NAME="$1"
REPOSITORY="$2"
BASE_URL="https://github.com/${REPOSITORY}/releases/download/${TAG_NAME}"
# suffix|human label, same order as docs/content/docs/downloads/_index.md
PLATFORMS=(
"linux-arm64|Raspberry Pi (64-bit) / ARM64 Linux"
"linux-armv7|Raspberry Pi (32-bit) / ARMv7"
"linux-amd64|Linux (64-bit PC)"
"darwin-arm64|macOS (Apple Silicon)"
"darwin-amd64|macOS (Intel)"
"windows-amd64.exe|Windows (64-bit)"
"freebsd-amd64|FreeBSD (64-bit)"
)
build_table() {
local BINARY_NAME=$1
echo "| Platform | Download | Checksum |"
echo "|---|---|---|"
for ENTRY in "${PLATFORMS[@]}"; do
local SUFFIX="${ENTRY%%|*}"
local LABEL="${ENTRY##*|}"
local FILENAME="${BINARY_NAME}-${TAG_NAME}-${SUFFIX}"
echo "| ${LABEL} | [${FILENAME}](${BASE_URL}/${FILENAME}) | [sha256](${BASE_URL}/${FILENAME}.sha256) |"
done
}
SERVICE_TABLE="$(build_table soundtouch-service)"
CLI_TABLE="$(build_table soundtouch-cli)"
cat << EOF
<!-- quick-downloads:start -->
## Quick downloads
Most people only need one of these two:
**soundtouch-service** — the local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
$SERVICE_TABLE
**soundtouch-cli** — command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
$CLI_TABLE
Everything else (soundtouch-player, soundtouch-backup, other platforms, Docker, install scripts): [Downloads page](https://gesellix.github.io/Bose-SoundTouch/docs/downloads/).
<!-- quick-downloads:end -->
EOF
+12 -17
View File
@@ -5,8 +5,18 @@ set -e
LIB_DIR="pkg/service/soundtouchweb/static/lib"
mkdir -p "$LIB_DIR"
echo "Installing static frontend dependencies from package-lock.json..."
npm ci --ignore-scripts
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
# Copy files from node_modules
echo "Copying Preact..."
@@ -18,19 +28,4 @@ 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."