Compare commits

..
44 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Sonnet 5 4478863d7c test(service): update router route snapshot for the new zone candidates endpoint
TestPrintRoutes compares against a checked-in route list; the new
GET .../zone/candidates route (added for Zone.js's candidate source
fix) needs to be reflected there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 862ddbb87d fix(player): follow Library's selected device to its pair master
If the device selected in the Library tab disappeared from the
devices map (e.g. it just became a hidden stereo-pair member per
device_projection.go), the sync effect fell back to entries[0][0] --
whichever key happens to sort first in the map -- silently redirecting
the user's Library browsing session to an unrelated speaker.

Now checks first whether the vanished device reappears as a member of
some other device's stereoPair (the pair's master, which now
represents the same physical speaker for control purposes) and
follows it there. Only falls back to an arbitrary device when the
selection is gone for a genuinely unrelated reason (removed, discovery
gap), matching the prior behavior for that case.

No JS unit-test framework exists in this repo for component-level
logic (consistent with the rest of the client-side code), so this is
verified by manual trace rather than an automated regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 78c6c1bfca fix(player): make Zone.js source zone candidates independently of Group projection
Zone.js derived its "add speaker" candidate list from the `devices`
prop, which is app.js's projected/collapsed device list. Once the
stereo-pair projection (device_projection.go) started hiding a pair's
non-master member from that list, it silently became impossible to
add that physical device to an unrelated multiroom zone, even though
the backend's HandleZoneAdd/HandleZoneRemove already operate on the
raw device registry directly and never cared about pairing at all.
Zone.js's own file wasn't touched by that change; its effective input
just changed underneath it.

Added GET /api/control/devices/{id}/zone/candidates, deliberately
bypassing deviceViewSnapshot's projection and deliberately not
excluding {id} itself -- which candidates to exclude is a caller
concern (Zone.js already does this via the existing zoneIps set,
which includes the zone master's own IP even when standalone, per
models.ZoneInfo.IsStandalone). Zone and Group are separate, unrelated
groupings and should stay that way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 522c6b8cb6 fix(models): make group-equality order-insensitive everywhere
sameGroupClaim (device_projection.go, used to validate a member's
claim agrees with the master's) compared roles via a device-ID-keyed
map, making it order-insensitive. webtypes.replaceGroup's change
detection used reflect.DeepEqual on the whole *Group, which is
order-sensitive for Roles.Roles. Both the polled /getGroup response
and the pushed groupUpdated event populate Roles.Roles directly from
XML unmarshaling in wire order, so nothing guarantees a pair's roles
list in the same order across two reads -- DeepEqual could then report
a spurious "changed" for a pair that didn't actually change.

Extracted the order-insensitive comparison into models.SameGroup as
the single shared implementation (also handles the nil/nil case
correctly, unlike the old sameGroupClaim, which mattered for
replaceGroup's existing "no prior group" path). Both call sites now
use it; the duplicate sameGroupClaim is gone.

Added TestApplyGroupEventIgnoresRoleOrder, verified to fail against
the prior DeepEqual-based logic and pass with this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 9629d3e057 fix(player): don't mark a device connected on GetGroup success alone
statusUpdated (which drives IsConnected) ORed in
"stereoCapable && groupErr == nil" alongside the five substantive
status fetches. Since GetGroup is gated to stereo-capable models and
trivially succeeds even when a device is struggling (an empty
<group/> is a near-guaranteed reply, per Client.GetGroup's doc
comment), a round where NowPlaying/Volume/Presets/Sources/Bass all
fail but GetGroup alone succeeds would still report the device
connected -- masking a real status-refresh failure specifically on
ST10 hardware.

Removed the extra OR term entirely: IsConnected now depends only on
the five substantive fetches, matching the comment's own stated
intent ("mirrors prior behaviour"). GetGroup's own success/failure
still drives whether Group gets refreshed (unchanged, see
ApplyPolledGroup below), just no longer feeds the connectivity signal.

Added TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds,
verified to fail against the prior logic and pass with this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 b180796ed4 docs(client): capture exact ST20 /getGroup failure mode and supportedURLs caveat
Refines the previous commit's doc fix with more precise, hardware-
verified detail: the ST20 doesn't just silently drop the connection --
its own firmware ("AllegroWebserver") eventually returns an explicit
"AllegroWebserver timeout: /getGroup" plain-text error after an
internal delay of several+ seconds, well past what client.get()'s
timeout will tolerate.

Also documents a dead-end a future contributor might otherwise try:
the ST20's own /supportedURLs response lists /getGroup (and the other
group endpoints) despite not actually servicing it, confirmed against
the same real hardware. A supportedURLs-based capability probe would
not have caught this either -- the model-name check has to stay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 68d8719be0 docs(client): correct GetGroup's non-ST10 behavior claim
The exported GetGroup doc comment claimed non-ST10 devices reply to
/getGroup "harmlessly" with an empty group. Verified against real
hardware this is wrong: a SoundTouch 20 does not reply at all -- the
request hangs until the client's own timeout (10-30s depending on how
the Client was constructed) instead of returning quickly. Confirmed
by direct request against a real ST20 (curl, 8s timeout, zero bytes
back) and cross-checked against two actively-paired real ST10 units,
which both replied in ~30-40ms with full group data.

This matters beyond prose accuracy: the newer stereoPairCapable gate
in websocket.go's UpdateDeviceStatus is load-bearing, not an
optimization. A future contributor trusting the old (wrong, and more
prominent/exported) doc could reasonably "simplify" by removing that
gate, reintroducing a 10-30s hang on every poll cycle for every
SoundTouch 20/30 on the network. Rewrote the doc to state the real
behavior and point at the gate that depends on it; the websocket.go
comment now defers to this doc instead of independently (and
incorrectly worded) restating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00
Lukáš Lipinský 342cd47e6a fix(player): snapshot device timestamps safely 2026-08-30 22:27:56 +02:00
Lukáš Lipinský 3b04a6eb57 fix(player): skip stereo polling on unsupported models 2026-08-30 22:27:56 +02:00
Lukáš Lipinský ce66b103b4 fix(player): keep stereo updates coherent and compatible 2026-08-30 22:27:56 +02:00
Lukáš Lipinský 2f2d886e24 docs(player): mark stereo pair projection implemented 2026-08-30 22:27:56 +02:00
Lukáš Lipinský b01ab1e1bb fix(player): project stereo pairs as logical devices 2026-08-30 22:27:56 +02:00
Tobias GesellchenandClaude Sonnet 5 79eb5dd038 docs(player): restore the async=false rationale and its regression test
PR #664 rewrote the comment explaining why the dynamically-inserted
es-module-shims script sets async = false, replacing the actual
execution-order reasoning (it must run before the deferred
type="module" script below, without blocking the parser for browsers
that never reach this branch) with a vaguer, inaccurate description
("inspects module graphs that fail static linking") that doesn't
explain the async choice at all. It also dropped the regression test
asserting .async = false is present, so a future "cleanup" removing
that line would go uncaught -- and the misleading comment no longer
warns against doing so.

The actual .async = false code was untouched by #664; this only
restores the documentation and its test coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 21:05:02 +02:00
Lukáš Lipinský d3893dff76 chore(player): tighten shim dependency handling 2026-08-30 21:05:02 +02:00
Tobias GesellchenandClaude Sonnet 5 14437fd568 fix(example-dlna-server): sanitize logged request values
CodeQL alert 313 (go/log-injection). The access-log middleware logged
r.URL.Path and SOAP-body-derived objectID/browseFlag verbatim, without
stripping newlines -- an attacker-controlled request could inject fake
log lines or control characters. Add the same sanitizeLog helper this
repo already uses in ~18 other packages for exactly this class of
finding.

Alert 312 (go/reflected-xss, same file/area) was investigated and left
open deliberately: objectID is only ever used as a lookup key in
pkg/dlna/dlnatest, never echoed into the response, and every actual
output field goes through xmlEsc/xmlAttr (encoding/xml.EscapeText)
before being written -- looks like a CodeQL false positive rather than
a real gap, but not dismissing it yet per discussion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 966214c5a0 fix(player): use hasOwnProperty for the device-status guard
CodeQL alert 318 (js/remote-property-injection). setDevices guarded
the status_update write with a plain "!prev[msg.deviceId]" truthy
check; a deviceId of "__proto__" or "constructor" resolves through
the prototype chain to a truthy value, so it would pass the guard
despite not being a real known device, letting the spread write a
bogus own-property (not actual prototype pollution -- computed keys
in object literals use [[DefineOwnProperty]], not the legacy __proto__
setter -- but still corrupts the rendered device list). Use
Object.prototype.hasOwnProperty.call for a real own-property check;
avoided Object.hasOwn (ES2022, Safari 15.4+) given #649's recent
Safari-15.0 compatibility work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 ed09141175 fix(player): remove leftover debug console.log
CodeQL alert 319 (js/log-injection). This logged the WebSocket
discovery_status payload verbatim to the browser console under a
"[DEBUG_LOG]" tag -- development-only cruft left in, not something
that serves any product purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 3dbf4bcd7e fix(admin-ui): remove unused session variable
CodeQL alert 320 (js/unused-local-variable). The interactions table
never had a session column; i.session/i.Session was extracted but
never referenced anywhere in the row template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 23:19:32 +02:00
Tobias GesellchenandClaude Sonnet 5 cff94d5d96 fix(bmx): correct misleading docs, restore TuneIn token request validation
HandleTuneInToken's docstring described the minted token as "fresh",
but datastore.GenerateSerialSecret("tunein") is a pure function of a
hardcoded literal -- it returns the identical value for every device
and every call, not a per-session secret. Correct the framing and
document why the constant value is safe today (Authorization gate
disabled for all TuneIn handlers, nothing validates uniqueness), so a
future change relying on per-device uniqueness doesn't get misled.

Also restore request-body validation dropped when the handler stopped
using the body's values: a genuine bootstrap call is still well-formed
JSON (confirmed against a captured real request), just with an empty
refresh_token, so decoding-but-discarding the body still rejects only
truly malformed requests with 400, without reintroducing the original
echo bug.

Also fixes 4 pre-existing wsl_v5 lint findings in the reordered
children-check in bmx/tunein.go (whitespace only, no behavior change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:46:52 +02:00
YongXin 22d77c23f3 add TestTuneInSectionsAshx_UntypedContainerSurfacesStations 2026-08-29 17:46:52 +02:00
Will c1342360e0 Update test 2026-08-29 17:46:52 +02:00
Will 03c42d1909 Fix TuneIn browse can't be played 2026-08-29 17:46:52 +02:00
Will 255023702e update 2026-08-29 17:46:52 +02:00
Will 1c7f32e736 Fix the bug of the child not showing 2026-08-29 17:46:52 +02:00
Tobias GesellchenandClaude Sonnet 5 734b921ac1 ci: exclude vendored static JS libs from CodeQL analysis
es-module-shims.js (vendored verbatim from npm) tripped 3 CodeQL
findings (js/incomplete-sanitization, js/bad-code-sanitization x2) --
real escaping-order bugs in the library's own source, verified by hand,
but not reachable in how this project uses it (no dynamic import()
built from untrusted input, no CSP nonce ever set). Reported upstream
separately.

The javascript-typescript CodeQL matrix entry had no path exclusions at
all, unlike the existing Go config's paths-ignore for vendor/generated
code, so preact.module.js and htm.module.js were exposed to the same
risk even though neither had tripped a finding yet. Add a JS-specific
config excluding pkg/service/soundtouchweb/static/lib/** -- we don't
control or modify these files, so findings there aren't actionable
from this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 de30aa8d7f ci(player): add a separate workflow for browser compatibility tests
Runs the opt-in "make test-browser" chromedp tests (added in the
previous commit) on their own, independent of ci.yml's main test job,
since they need a Chrome/Chromium binary in the runner. Mirrors
ci.yml's checkout/setup-go/cache steps and pinned action versions, and
verifies google-chrome is present with a clear error message before
running, rather than surfacing a cryptic chromedp allocator failure if
the runner image ever stops shipping it preinstalled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 efcb96998f test(player): add browser-level compatibility tests via chromedp
Adds two opt-in tests (build tag "browsertest", run via `make
test-browser`) that drive a real headless Chrome instead of only
asserting on the raw HTML/JS source:

- TestPlayerRendersNatively confirms the shipped page still renders
  normally and never injects es-module-shims on a browser with native
  import map support.
- TestPlayerRendersUnderForcedShimMode forces es-module-shims into its
  own shimMode (importmap-shim/module-shim, per the library's docs),
  routing the real app.js and vendored dependencies through the
  library's actual polyfill resolution. This exercises the old-Safari
  code path directly in CI/local headless Chrome, without needing
  physical iPadOS 15 hardware.

Not wired into `test`/`check`/CI yet, since chromedp has not
previously been exercised as part of this repo's test suite (only in
the standalone doc-screenshot tool) and needs a Chrome/Chromium binary
available to the runner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 966d9962a5 fix(player): feature-detect es-module-shims instead of always loading it
Loading es-module-shims unconditionally would charge every browser an
~80KB uncompressed download on every page view, including the vast
majority that already support import maps natively -- the static
asset server here applies no compression. Feature-detect
HTMLScriptElement.supports('importmap') instead, so only a browser
that actually lacks support ever fetches it.

Insert the script via the DOM with async = false rather than
document.write: document.write is deprecated and subject to browser
interventions that can silently drop externally-sourced scripts it
injects, where DOM insertion with async explicitly disabled gives the
same before-the-deferred-module-script execution guarantee without
that risk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 6b2c3b5c7c fix(player): use es-module-shims instead of removing import maps
Restores the import map and bare-specifier imports across all
components, and instead polyfills import map support for Safari on
iPadOS 15 (which has ES modules but not import maps) via
es-module-shims, loaded unconditionally since it detects native
support and no-ops there.

The previous approach converted every component to relative imports
through a dependencies.js facade and permanently sed-patched the
vendored preact-hooks build, baking the compatibility workaround into
the whole codebase instead of keeping it encapsulated in index.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 17:09:36 +02:00
Lukáš Lipinský 6752f71e67 refactor(player): centralize static dependencies 2026-08-29 17:09:36 +02:00
Lukáš Lipinský c97e9958f6 fix(player): support browsers without import maps 2026-08-29 17:09:36 +02:00
Tobias GesellchenandClaude Sonnet 5 35724bcffc test(service): fix flaky serialization test by filtering non-/info requests
The handler counted every request regardless of path, so the background
status-update goroutine AddDeviceByHost spawns after a successful probe
could land before the assertion and be mistaken for a second concurrent
seed probe, occasionally failing with request count 2 instead of 1. Filter
by path like the existing TestDiscoverDevicesRetriesConfiguredHosts test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 16:14:56 +02:00
Tobias GesellchenandClaude Sonnet 5 a21b4d71ae fix(service): address code-review findings on PR #652's startup retry
Fixes correctness issues found reviewing the bounded device-seed retry
loop before merging: a datastore read failure could make the readiness
check trivially pass; stale-host pruning only considered hosts inserted
in the current attempt and only ran inside the retry loop, not the
plain SeedExtraDevices path; the retry loop and a devices-changed-hook
seed could probe the same offline host concurrently; and a zero-change
startup window silently dropped the previously-unconditional device-list
broadcast. Also makes the retry interval/window configurable instead of
hardcoded, following the existing discovery-interval flag pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 16:14:56 +02:00
Lukáš Lipinský 0d15bce96f fix(service): retry persisted player devices after startup 2026-08-29 16:14:56 +02:00
dependabot[bot] 719cc446e6 ci(deps): bump the codeql-action group with 3 updates
Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/analyze` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

Updates `github/codeql-action/upload-sarif` from 4.37.7 to 4.37.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd...db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 20:47:35 +02:00
Tobias GesellchenandClaude Sonnet 5 b10e6dfe8f docs(troubleshooting): add entry for reboot preset wipe on shared-account setups
Closes the loop on #614: presets wiped after a reboot when a speaker
shares its Marge account with other devices. Root cause stays
unconfirmed (firmware-internal), but removing the other devices from
the account is a reporter-confirmed workaround.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 20:34:16 +02:00
Tobias GesellchenandClaude Sonnet 5 44830790b8 test(player): adopt httptest.NewTestServer (Go 1.27) in the new discovery test
NewTestServer registers its own t.Cleanup(Close) instead of needing a
manual defer, and fails the test on a handler panic instead of just
logging it. It defaults to an in-memory transport reachable only via
Server.Client(), which wouldn't work here since our production
client.NewClient dials a real address rather than using that client --
calling Start() instead of Client() opts back into a real loopback
listener, identical to the old NewServer, confirmed by reading the
actual go1.27.0 source (server.go's Start implementation).

This is a proactive adoption of a new stdlib idiom, not one of the
review findings from the previous commit; it doesn't change the
goroutine-drain fix from that commit, which is a separate concern
Close()'s "wait for outstanding requests" guarantee doesn't fully
cover (a goroutine that hasn't started its request yet at Close() time
isn't "outstanding").

Verified: 10x -count re-run under -race, full suite + lint clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:00:11 +02:00
Tobias GesellchenandClaude Sonnet 5 df62073ab0 fix(player): address code review findings on #644
Five findings from reviewing gesellix/Bose-SoundTouch#644
(Retry configured player devices during discovery):

1. Source-labeling used exact string equality
   (device.DiscoveryMethod == "Configuration"), which breaks once a
   configured host is also found via mDNS/UPnP in the same sweep:
   mergeDeviceData concatenates methods into e.g.
   "Configuration+mDNS/Bonjour", so the check silently failed and the
   device got labeled "discovered" instead of "manual". Extracted the
   decision into classifySource() and switched to a substring match.
   Added TestClassifySource, which fails against the old exact-equality
   logic on exactly the composite-string cases (verified) and would
   have caught this before merge -- the PR's own test disables
   mDNS/UPnP, so it never exercised this path.

2. Manually configured devices no longer registered immediately at
   startup -- they now wait for the full mDNS/UPnP sweep (up to the
   10s discovery timeout) to complete, since the PR removed the
   synchronous registration loop and relies entirely on
   PreferredDevices. Restored the immediate loop alongside (not
   instead of) folding manualHosts into PreferredDevices, so a
   currently-online configured device registers immediately as
   before, while an offline one still gets retried on every
   subsequent discovery pass -- the actual value this PR adds.

3. The new PreferredDevices-seeding loop didn't dedupe against hosts
   already loaded from PREFERRED_DEVICES, so setting both for the same
   host produced duplicate entries. Currently harmless (absorbed by
   AddDeviceByHost's fast path) but fragile. Added dedup by host.

4. NewDiscoveryService's doc comment didn't mention the new
   configuredHosts parameter or its retry-on-every-sweep behavior.
   Documented.

5. The new test's second DiscoverDevices call spawns a one-shot
   status-update goroutine and a 30s-ticker poll loop with no
   guaranteed drain before the deferred server.Close(), risking
   benign but real -race/CI flakiness. Added a bounded settle delay
   after RemoveDevice.

Verified: full build/vet/race test suite/lint clean; the new
TestClassifySource fails against the pre-fix logic and passes with
it; TestDiscoverDevicesRetriesConfiguredHosts re-run 20x under -race
with no flakiness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:00:11 +02:00
Lukáš Lipinský 7015e04556 fix(player): retry configured devices during discovery 2026-08-23 22:00:11 +02:00
Tobias GesellchenandClaude Sonnet 5 d3c5ad2d8e style(install): align the environment variables table columns
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 ef8bfdc74b docs(install): add an environment variables reference table
The nine env vars install.sh reads were only ever mentioned inline,
scattered across the file, or not documented at all
(AFTERTOUCH_FORCE_NO_BACKUP, GH_REPO, BINARY_URL, INIT_SCRIPT_URL,
FALLBACK_VERSION, AFTERTOUCH_LAN_PORT). Collect them into one table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 fdc08d2745 docs(install): replace stale "we're unsure how to update" note
The Space Limitation section still described update safety as an open
problem ("we are currently working on this"). Replace it with what the
installer now actually does: gzip-compressed backups, a preflight
disk-space check with an interactive confirm-or-abort before skipping
the backup, and a hard abort before downloading anything if there
isn't even room for the update itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 ec340847f2 fix(install): preflight disk-space check before replacing the live binary
The gzip fix in the previous commit only helps once a backup is being
made; it doesn't address the actual moment that broke on real hardware:
the cross-device mv/copy of the new binary into place ran out of space
mid-write, leaving a truncated, non-executable binary as the live one.
UBIFS is a log-structured flash filesystem, so space "freed" by
overwriting the old binary isn't guaranteed reusable in time for the new
one to land -- this happened on a device with 15.7MB available against
a ~14.8MB binary.

Add a preflight check before downloading anything: fetch the new
binary's real size via a HEAD request (adapts automatically as binaries
grow, instead of a threshold that goes stale every release) and compare
against available space plus a flat 5MB safety margin.

- Comfortably enough room for old + new + a compressed backup: proceed
  exactly as before, silently.
- Enough for old + new but not enough extra for a backup: warn
  interactively and require explicit confirmation before proceeding
  without one. Reads from /dev/tty since the script is normally piped
  via `curl | sh` (stdin is consumed by the script itself). Defaults to
  the safe choice (abort) on empty input, matching the [y/N] prompt.
  AFTERTOUCH_FORCE_NO_BACKUP=yes overrides for non-interactive/scripted
  use.
- Not enough room even for the replace itself: abort before starting
  the download, rather than attempting a doomed download/replace that
  could leave a truncated live binary.
- No TTY available and the operator didn't set the override: abort
  rather than silently guessing.
- HEAD request fails for any reason: skip the check with a warning
  rather than blocking the install on it.

Verified: all five decision branches (plenty of room, warn+decline,
warn+confirm, warn+forced-override, hard abort) produce the correct
result under both dash and a real BusyBox v1.38.0 container, including
the gzip/gunzip streaming backup and glob-based pruning from the
previous commit. The HEAD-request size lookup was separately verified
against a live GitHub release URL with real curl -- catching and fixing
a bug where naively taking the first "content-length:" header grabbed
the 302 redirect's (0), not the actual asset's, size. Not yet re-tested
end-to-end on real hardware.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
Tobias GesellchenandClaude Sonnet 5 e8f1b53992 fix(install): gzip the rollback backup to reduce on-device disk pressure
Binaries are tens of MB and only growing (Go 1.27 alone added ~655KB
to soundtouch-service via its own new stdlib defaults, unrelated to
this project's code), while the on-device install target (/mnt/nv) is
only tens of MB total. A user already hit "no space left on device"
attempting an update on real hardware.

Stream the pre-update backup straight through gzip instead of cp-then-
gzip: at that point in the script the old binary is still live and the
newly-downloaded one is already sitting in the temp dir, so writing an
intermediate uncompressed backup copy would briefly need three full
binary-sized copies on disk at once. Streaming avoids ever creating
that intermediate copy. Falls back to a plain uncompressed backup if
gzip is unavailable or the stream fails partway, matching prior
behavior exactly.

Both GC loops (pre- and post-install) now also prune stale
*.backup.gz artefacts, and the README's documented rollback command
covers both the compressed and (fallback) uncompressed cases.

Verified locally (not yet on real hardware): streaming path produces
no uncompressed intermediate, the gzip-unavailable fallback still
produces a plain backup, and the documented gunzip+chmod rollback
restores a byte-identical, executable binary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:50:27 +02:00
53 changed files with 4690 additions and 328 deletions
+18
View File
@@ -0,0 +1,18 @@
# CodeQL configuration
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
name: "JavaScript/TypeScript Security Analysis"
disable-default-queries: false
queries:
- uses: security-extended
- uses: security-and-quality
# Paths to exclude from analysis
paths-ignore:
- "**/node_modules/**"
# The minified es-module-shims distribution currently triggers findings in
# third-party code. Keep this exception file-specific so Preact, HTM, and
# future files under static/lib remain covered.
- "pkg/service/soundtouchweb/static/lib/es-module-shims.js"
+49
View File
@@ -0,0 +1,49 @@
name: Browser Compatibility Tests
permissions:
contents: read
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
test-browser:
name: Player browser tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod') }}-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Download dependencies
run: go mod download
- name: Verify a Chrome/Chromium binary is available
# chromedp (used by the browsertest-tagged tests) discovers Chrome on
# PATH or in a standard install location; GitHub's ubuntu-latest
# runner image ships Google Chrome preinstalled. Fail fast with a
# clear message here instead of a cryptic chromedp allocator error
# if that image ever stops including it.
run: google-chrome --version
- name: Run browser-level player compatibility tests
run: make test-browser
+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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || matrix.language == 'javascript-typescript' && './.github/codeql-config-js.yml' || '' }}
- name: Build Go (required for manual build-mode)
if: matrix.language == 'go'
run: go build ./...
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
sarif_file: semgrep.sarif
continue-on-error: true
+11 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
.PHONY: all build build-cli test test-coverage test-browser test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
# Load .env if present (simple KEY=VALUE format, no shell quoting)
-include .env
@@ -147,6 +147,15 @@ test-coverage:
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
# Browser-level regression tests for the embedded player's static assets
# (see pkg/service/soundtouchweb/browser_compatibility_test.go). Opt-in via
# the "browsertest" build tag, not part of `test`/`check`, since they need a
# Chrome/Chromium binary that chromedp can find on PATH or in a standard
# install location.
test-browser:
@echo "Running browser-level compatibility tests..."
$(GOTEST) -tags browsertest -v ./pkg/service/soundtouchweb/...
check: fmt vet test test-http-client
# Archive any existing tests/integration/testdata/ to a timestamped sibling
@@ -501,6 +510,7 @@ help:
@echo " build-linux-armv7 - Build for Linux ARMv7 (kernel 3.14+ compatible, CGO_ENABLED=0)"
@echo " test - Run tests"
@echo " test-coverage - Run tests with coverage report"
@echo " test-browser - Run browser-level (chromedp) player compatibility tests"
@echo " test-http-client - Run .http integration tests via Docker Compose"
@echo " test-http-client-rotate - Archive tests/integration/testdata/ before a fresh run (non-destructive)"
@echo " check - Run fmt, vet, and tests"
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from HTTP requests may contain
// attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+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", between(string(body), "<ObjectID>", "</ObjectID>"),
"browseFlag", between(string(body), "<BrowseFlag>", "</BrowseFlag>"),
"objectID", sanitizeLog(between(string(body), "<ObjectID>", "</ObjectID>")),
"browseFlag", sanitizeLog(between(string(body), "<BrowseFlag>", "</BrowseFlag>")),
}
}
@@ -664,7 +664,7 @@ func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
attrs := []any{
"method", r.Method,
"path", r.URL.Path,
"path", sanitizeLog(r.URL.Path),
"status", rec.status,
"bytes", rec.bytes,
"from", r.RemoteAddr,
+2 -2
View File
@@ -88,7 +88,7 @@ go build -o soundtouch-player
./soundtouch-player -port 8888
# Connect to specific device
./soundtouch-player -host 192.0.2.100
./soundtouch-player --devices 192.0.2.100
```
### Command Line Options
@@ -137,7 +137,7 @@ device datastore).
The application automatically discovers SoundTouch devices using:
- **mDNS discovery** for local network devices
- **UPnP/SSDP discovery** as fallback
- **Manual device addition** via IP address
- **Configured devices** via `--devices`, retried whenever discovery runs
### Real-time Updates
The interface maintains WebSocket connections to each device for instant updates of:
+6 -1
View File
@@ -161,7 +161,7 @@ func main() {
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
}
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName, manualHosts...)
// Discover devices on startup
go func() {
@@ -170,6 +170,11 @@ func main() {
webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount())
// Register configured devices immediately rather than waiting for
// the full mDNS/UPnP sweep below (bounded by cfg.DiscoveryTimeout,
// currently 10s) to complete. manualHosts are also folded into
// discoveryService's PreferredDevices so a host that's offline
// right now still gets retried on every subsequent discovery pass.
for _, host := range manualHosts {
webApp.AddDeviceByHost(host, 8090, "manual")
}
+151 -107
View File
@@ -300,6 +300,18 @@ var serviceFlags = []cli.Flag{
Value: "5m",
EnvVars: []string{"DISCOVERY_INTERVAL"},
},
&cli.StringFlag{
Name: "device-seed-retry-interval",
Usage: "Interval between embedded-player startup retries for unreachable persisted devices",
Value: "30s",
EnvVars: []string{"DEVICE_SEED_RETRY_INTERVAL"},
},
&cli.StringFlag{
Name: "device-seed-retry-window",
Usage: "Bounded window during which the embedded player retries unreachable persisted devices at startup",
Value: "10m",
EnvVars: []string{"DEVICE_SEED_RETRY_WINDOW"},
},
&cli.BoolFlag{
Name: "update-check-enabled",
Usage: "Periodically check GitHub for a newer release (opt-in; the only network call this makes beyond speaker/provider traffic)",
@@ -656,7 +668,7 @@ func main() {
}
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds, config.deviceSeedRetryInterval, config.deviceSeedRetryWindow)
r := setupRouter(server, stockholmHandler, webApp)
@@ -715,55 +727,57 @@ func showVersionInfo(_ *cli.Context) error {
}
type serviceConfig struct {
port string
bindAddr string
addr string
dataDir string
hostname string
serverURL string
httpsServerURL string // effective (derived or overridden)
httpsOverride string // explicit override; "" = derive from serverURL
httpsPort string
httpsDefaultURL string // hostname-based fallback
httpsAddr string
redact bool
logBody bool
record bool
dnsEnabled bool
dnsUpstream string
dnsBind string
internalPaths []string
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
updateCheckEnabled bool
updateCheckInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonTokenURL string
amazonProfileURL string
tuneInOpmlURL string
tuneInAPIURL string
mgmtUsername string
mgmtPassword string
ttsProvider string
ttsGoogleAPIKey string
ttsGoogleEndpoint string
ttsLanguage string
ttsVoice string
ttsAppKey string
ttsVolume int
migrationEnabled bool
migrationDryRun bool
stockholmDir string
stockholmBasePath string
port string
bindAddr string
addr string
dataDir string
hostname string
serverURL string
httpsServerURL string // effective (derived or overridden)
httpsOverride string // explicit override; "" = derive from serverURL
httpsPort string
httpsDefaultURL string // hostname-based fallback
httpsAddr string
redact bool
logBody bool
record bool
dnsEnabled bool
dnsUpstream string
dnsBind string
internalPaths []string
tlsExtraHosts []string
discoveryEnabled bool
discoveryInterval time.Duration
deviceSeedRetryInterval time.Duration
deviceSeedRetryWindow time.Duration
updateCheckEnabled bool
updateCheckInterval time.Duration
domains []string
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
amazonClientID string
amazonClientSecret string
amazonRedirectURI string
amazonTokenURL string
amazonProfileURL string
tuneInOpmlURL string
tuneInAPIURL string
mgmtUsername string
mgmtPassword string
ttsProvider string
ttsGoogleAPIKey string
ttsGoogleEndpoint string
ttsLanguage string
ttsVoice string
ttsAppKey string
ttsVolume int
migrationEnabled bool
migrationDryRun bool
stockholmDir string
stockholmBasePath string
}
// resolveFallbackHost picks the host used to guess a server URL when
@@ -860,6 +874,24 @@ func loadConfig(c *cli.Context) (serviceConfig, error) {
discoveryInterval = 5 * time.Minute
}
deviceSeedRetryIntervalStr := c.String("device-seed-retry-interval")
deviceSeedRetryInterval, err := time.ParseDuration(deviceSeedRetryIntervalStr)
if err != nil {
log.Printf("Warning: Failed to parse device seed retry interval %s, using default 30s: %v", sanitizeLog(deviceSeedRetryIntervalStr), err)
deviceSeedRetryInterval = 30 * time.Second
}
deviceSeedRetryWindowStr := c.String("device-seed-retry-window")
deviceSeedRetryWindow, err := time.ParseDuration(deviceSeedRetryWindowStr)
if err != nil {
log.Printf("Warning: Failed to parse device seed retry window %s, using default 10m: %v", sanitizeLog(deviceSeedRetryWindowStr), err)
deviceSeedRetryWindow = 10 * time.Minute
}
updateCheckEnabled := c.Bool("update-check-enabled")
updateCheckIntervalStr := c.String("update-check-interval")
@@ -898,55 +930,57 @@ func loadConfig(c *cli.Context) (serviceConfig, error) {
stockholmBasePath := c.String("stockholm-base-path")
return serviceConfig{
port: port,
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: fallbackHost,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsOverride: httpsOverride,
httpsPort: httpsPort,
httpsDefaultURL: httpsDefaultURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
internalPaths: internalPaths,
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
updateCheckEnabled: updateCheckEnabled,
updateCheckInterval: updateCheckInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
amazonClientID: amazonClientID,
amazonClientSecret: amazonClientSecret,
amazonRedirectURI: amazonRedirectURI,
amazonTokenURL: amazonTokenURL,
amazonProfileURL: amazonProfileURL,
tuneInOpmlURL: tuneInOpmlURL,
tuneInAPIURL: tuneInAPIURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
ttsProvider: ttsProvider,
ttsGoogleAPIKey: ttsGoogleAPIKey,
ttsGoogleEndpoint: ttsGoogleEndpoint,
ttsLanguage: ttsLanguage,
ttsVoice: ttsVoice,
ttsAppKey: ttsAppKey,
ttsVolume: ttsVolume,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
stockholmBasePath: stockholmBasePath,
port: port,
bindAddr: bindAddr,
addr: addr,
dataDir: dataDir,
hostname: fallbackHost,
serverURL: serverURL,
httpsServerURL: httpsServerURL,
httpsOverride: httpsOverride,
httpsPort: httpsPort,
httpsDefaultURL: httpsDefaultURL,
httpsAddr: httpsAddr,
redact: redact,
logBody: logBody,
record: record,
dnsEnabled: dnsEnabled,
dnsUpstream: dnsUpstream,
dnsBind: dnsBind,
internalPaths: internalPaths,
tlsExtraHosts: tlsExtraHosts,
discoveryEnabled: discoveryEnabled,
discoveryInterval: discoveryInterval,
deviceSeedRetryInterval: deviceSeedRetryInterval,
deviceSeedRetryWindow: deviceSeedRetryWindow,
updateCheckEnabled: updateCheckEnabled,
updateCheckInterval: updateCheckInterval,
domains: domains,
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
amazonClientID: amazonClientID,
amazonClientSecret: amazonClientSecret,
amazonRedirectURI: amazonRedirectURI,
amazonTokenURL: amazonTokenURL,
amazonProfileURL: amazonProfileURL,
tuneInOpmlURL: tuneInOpmlURL,
tuneInAPIURL: tuneInAPIURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
ttsProvider: ttsProvider,
ttsGoogleAPIKey: ttsGoogleAPIKey,
ttsGoogleEndpoint: ttsGoogleEndpoint,
ttsLanguage: ttsLanguage,
ttsVoice: ttsVoice,
ttsAppKey: ttsAppKey,
ttsVolume: ttsVolume,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
stockholmBasePath: stockholmBasePath,
}, nil
}
@@ -1427,7 +1461,7 @@ func runUpdateCheckTick(checker *updatecheck.Checker, lastLoggedVersion string)
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
// devices-changed hook re-syncs the UI registry whenever the service's
// discovery or a manual add changes the set.
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore, deviceSeedRetryInterval, deviceSeedRetryWindow time.Duration) *soundtouchweb.WebApp {
webApp := soundtouchweb.NewWebApp()
webApp.Version = version
webApp.Commit = commit
@@ -1444,11 +1478,10 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
// stream URLs the speaker fetches and the UI displays it.
webApp.InternalServiceURL = internalURL
webApp.ExtraDeviceHosts = func() []string {
webApp.ExtraDeviceHosts = func() ([]string, error) {
devices, listErr := ds.ListAllDevices()
if listErr != nil {
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
return nil
return nil, fmt.Errorf("web UI: failed to list devices from datastore: %w", listErr)
}
hosts := make([]string, 0, len(devices))
@@ -1458,7 +1491,7 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
}
}
return hosts
return hosts, nil
}
// UI "discover" runs the service's sweep, not a second mDNS stack.
@@ -1478,9 +1511,20 @@ func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, d
})
go func() {
// Project the current device set into the UI; the devices-changed hook
// and the service's periodic discovery keep it current from here on.
webApp.SeedExtraDevices()
// Project the current device set into the UI. During gateway boot the
// service can start before persisted speaker addresses are routable, so
// retry only those known addresses for a bounded startup window. The
// devices-changed hook and explicit discovery keep it current afterwards.
ctx, cancel := context.WithTimeout(context.Background(), deviceSeedRetryWindow)
defer cancel()
webApp.SeedExtraDevicesUntilReady(ctx, deviceSeedRetryInterval)
// Unconditional: a WebSocket client connected during a window where
// every attempt inserted or removed nothing (e.g. no persisted devices
// at all, or every persisted host stayed unreachable for the whole
// window) must still see the current, converged device list once this
// goroutine's work is done.
webApp.BroadcastDeviceList()
}()
+52
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/urfave/cli/v2"
@@ -69,6 +70,57 @@ func TestResolveFallbackHost(t *testing.T) {
}
}
func TestLoadConfig_DeviceSeedRetryTuning(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t))
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if config.deviceSeedRetryInterval != 30*time.Second {
t.Errorf("deviceSeedRetryInterval = %s, want 30s", config.deviceSeedRetryInterval)
}
if config.deviceSeedRetryWindow != 10*time.Minute {
t.Errorf("deviceSeedRetryWindow = %s, want 10m", config.deviceSeedRetryWindow)
}
})
t.Run("flags override the defaults", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t,
"--device-seed-retry-interval=5s",
"--device-seed-retry-window=1m"))
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if config.deviceSeedRetryInterval != 5*time.Second {
t.Errorf("deviceSeedRetryInterval = %s, want 5s", config.deviceSeedRetryInterval)
}
if config.deviceSeedRetryWindow != time.Minute {
t.Errorf("deviceSeedRetryWindow = %s, want 1m", config.deviceSeedRetryWindow)
}
})
t.Run("unparseable values fall back to the defaults", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t,
"--device-seed-retry-interval=not-a-duration",
"--device-seed-retry-window=also-not-a-duration"))
if err != nil {
t.Fatalf("loadConfig() error = %v", err)
}
if config.deviceSeedRetryInterval != 30*time.Second {
t.Errorf("deviceSeedRetryInterval = %s, want fallback 30s", config.deviceSeedRetryInterval)
}
if config.deviceSeedRetryWindow != 10*time.Minute {
t.Errorf("deviceSeedRetryWindow = %s, want fallback 10m", config.deviceSeedRetryWindow)
}
})
}
func TestLoadConfig_DeploymentMode(t *testing.T) {
t.Run("on-device with no --server-url defaults to localhost", func(t *testing.T) {
config, err := loadConfig(newTestServiceContext(t, "--deployment-mode=on-device", "--port=8000"))
+1
View File
@@ -46,6 +46,7 @@ GET /api/control/devices/{id}/power-status soundtouch
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
GET /api/control/devices/{id}/zone/candidates soundtouchweb.(*WebApp).HandleGetZoneCandidates-fm
GET /api/control/providers/library/servers soundtouchweb.(*WebApp).HandleDiscoverLibraryServers-fm
GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
@@ -101,13 +101,13 @@ rename and network/firmware info.
---
## 4. Render stereo pairs as a single device
## 4. Render stereo pairs as a single device (shipped)
Today soundtouch-player shows the two halves of a stereo pair (formed via
`/addGroup` see issue #252) as independent entries in the device list. The
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
presentation closes the perception gap BirdyBA flagged at
<https://github.com/gesellix/Bose-SoundTouch/issues/252#issuecomment-4458140305>.
soundtouch-player projects a valid two-speaker stereo pair (formed via
`/addGroup` - see [issue #252](https://github.com/gesellix/Bose-SoundTouch/issues/252))
as one logical control target. This restores the single-entry presentation
expected by users while preserving both physical speakers in the service
registry.
**Device API:**
- `GET /getGroup` on each speaker — returns the current `<group>` with
@@ -118,28 +118,30 @@ presentation closes the perception gap BirdyBA flagged at
side is sufficient to detect the pair
**Backend:**
- During device-list assembly, call `GET /getGroup` for each discovered device
in parallel (matches the propagation pattern already used by
`soundtouch-cli group create` in `cmd/soundtouch-cli/cmd_group.go`)
- Bucket devices by `<masterDeviceId>` — each bucket emits one entry in the
list response. Standalone devices stay as their own bucket-of-one
- Expose pair metadata on the list entry so the UI can render role chips
(`L`/`R`) and resolve role → physical device for actions
- Poll `GET /getGroup` together with the other device status and consume
`groupUpdated` events. A generation check prevents an older poll from
overwriting a newer event.
- Collapse only an exact two-member `LEFT`/`RIGHT` group whose registered
members agree on the group claim. Malformed, conflicting, or ambiguous data
fails open and leaves the physical entries visible.
- Use the master speaker's existing registry key for the logical target, so
controls continue to route through the master without changing the raw
physical-device registry.
- Use the same projection for the REST device list and the global player
WebSocket snapshot.
**Frontend:**
- Device list collapses paired devices into one card titled with both names
(e.g. `"Wohnzimmer L+R"`) and role chips
- Clicking the card opens a device-detail page that exposes both per-role
status and a "Dissolve pair" action (DELETE flow, already wired in
`soundtouch-cli group remove` and in fakespeaker's `/removeGroup` GET)
- Standalone speakers continue to render as today
- Render one card using the shared member name or the group's name.
- Show pair availability as `Stereo pair n/2` and mark the card degraded when
a member is unavailable or the group reports a non-OK state.
- Hide the single-device remove action on a projected pair. Standalone
speakers continue to render as before.
**Note:** Pair lifecycle (create / rename / remove) already works
end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
against the fake speaker's group routes
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
presentation in soundtouch-player's device list — no protocol work required.
**Note:** Pair lifecycle (create / rename / remove) remains available through
the existing client and CLI group operations. The player intentionally does
not expose a "Dissolve pair" action yet: its current remove operation deletes
one physical registry record rather than performing an atomic pair lifecycle
operation.
---
+29 -27
View File
@@ -156,33 +156,35 @@ The service supports multiple ways to configure its behavior. When multiple sour
### Configuration Options
| Variable | Flag | Description | Default |
|------------------------------------|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `DISCOVERY_DISABLED` | | Disable automated device discovery | `false` |
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
| Variable | Flag | Description | Default |
|------------------------------------|--------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|
| `PORT` | `--port`, `-p` | HTTP port to bind the service to | `8000` |
| `BIND_ADDR` | `--bind` | Network interface to bind to | all (ipv4 and ipv6) |
| `DATA_DIR` | `--data-dir` | Directory for persistent data | `./data` |
| `SERVER_URL` | `--server-url`, `-s` | External URL of this service | `http://<hostname>:8000` |
| `DEPLOYMENT_MODE` | `--deployment-mode` | Where this service runs: `on-device`, `private-network`, or `public-network`. Only changes behavior when `SERVER_URL` is *not* set: `on-device` defaults to `http://localhost:<port>` instead of guessing a hostname (the speaker's own Linux hostname is never resolvable — see issue #546); `public-network` refuses to start rather than guess a publicly reachable address; unset/`private-network` keeps the previous hostname-guessing behavior, now with a startup warning. The on-device install script sets this automatically. | unset (legacy hostname guess, with warning) |
| `HTTPS_PORT` | `--https-port` | HTTPS port to bind the service to | `8443` |
| `HTTPS_SERVER_URL` | `--https-server-url`, `-S` | External HTTPS URL. An override: when empty it is derived from `SERVER_URL` (same host, `https`, on `HTTPS_PORT`), and can also be viewed/overridden in Settings. | derived from `SERVER_URL` |
| `PYTHON_BACKEND_URL`, `TARGET_URL` | `--target-url` | URL for Python-based service components (legacy) | `http://localhost:8001` |
| `REDACT_PROXY_LOGS` | `--redact-logs` | Redact sensitive data in proxy logs | `true` |
| `LOG_PROXY_BODY` | `--log-bodies` | Log full request/response bodies | `false` |
| `RECORD_INTERACTIONS` | `--record-interactions` | Record HTTP interactions to disk | `true` |
| `DISCOVERY_ENABLED` | `--discovery-enabled` | Enable periodic device discovery | `true` |
| `DISCOVERY_INTERVAL` | `--discovery-interval` | Device discovery interval | `5m` |
| `DEVICE_SEED_RETRY_INTERVAL` | `--device-seed-retry-interval` | Interval between embedded-player startup retries for persisted devices that failed their first probe (e.g. LAN not yet routable on a cold boot) | `30s` |
| `DEVICE_SEED_RETRY_WINDOW` | `--device-seed-retry-window` | Bounded window during which the embedded player retries those unreachable persisted devices at startup | `10m` |
| `ENABLE_DNS_DISCOVERY` | `--dns-discovery` | Enable DNS discovery server | `false` |
| `DNS_UPSTREAM` | `--dns-upstream` | Upstream DNS server for non-Bose queries | `8.8.8.8` |
| `DNS_BIND_ADDR` | `--dns-bind` | Bind address for the DNS discovery server (standard port `:53` is required for DNS/DHCP migration) | `:53` |
| `INTERNAL_PATHS` | `--internal-paths` | Paths for internal requests to exclude from recording (e.g., `/setup/*`, `/web/*`) | `[]` |
| `UPDATE_CHECK_ENABLED` | `--update-check-enabled` | Periodically check GitHub Releases for a newer version and show a dismissible notice in the admin UI and Player when one is found. **Opt-in**: this is the only network call AfterTouch makes beyond speaker/provider traffic when enabled, so it defaults off. One unauthenticated `GET` per interval to `api.github.com`, nothing else leaves the box. Also available as an "Update Check" toggle on the admin Settings page, which applies without a restart; the env var/flag is the seed value for a fresh install with no `settings.json` yet. | `false` |
| `UPDATE_CHECK_INTERVAL` | `--update-check-interval` | Update check interval. Also editable on the admin Settings page (applies without a restart). | `24h` |
| `MGMT_USERNAME` | `--mgmt-username` | Username for HTTP Basic Auth on the Management API (`/api/mgmt/*`, `/mgmt/*`) — Spotify/Amazon account linking, Local Accounts | `admin` |
| `MGMT_PASSWORD` | `--mgmt-password` | Password for the same Management API Basic Auth. **Change this if AfterTouch is reachable beyond a trusted LAN** — the default is published in this doc. | `change_me!` |
| `STOCKHOLM_DIR` | `--stockholm-dir` | Path to extracted Stockholm frontend directory — enables the Stockholm UI when set | *(disabled)* |
| `MARGE_URL` | | Streaming/marge base URL used when rewriting `stockholm/json/config.json`. Defaults to `SERVER_URL`. Set to `SERVER_URL/marge` only when using a soundcork backend. | *(same as `SERVER_URL`)* |
| `MARGE_AUTH_TOKEN` | | Pre-seeds the Stockholm `margeAuthToken` state (skips the login step for the first session) | *(empty)* |
| `MARGE_ACCOUNT_ID` | | Pre-seeds the Stockholm `margeAccountID` state (used to filter device-discovery results by account) | *(empty)* |
### Configuration Examples
@@ -594,6 +594,28 @@ Once the source plays once, it gets persisted to `/mnt/nv/BoseApp-Persistence/1/
If `soundtouch-cli source content --source TUNEIN ...` returns `1005` on a reset device that has never had TuneIn, the speaker is refusing because the source isn't registered yet — chicken-and-egg. The SoundTouch app is then the only practical path to register it; we can't write `Sources.xml` directly over telnet on most models.
### ❌ Presets get wiped after a reboot, on a speaker sharing its Marge account with other devices {#preset-wipe-shared-account}
**Symptoms:**
- Presets are programmed and confirmed correct (e.g. via the Admin UI or `soundtouch-cli`), but after a plain reboot of the speaker, its own preset list comes back empty (`<presets />`) — even though the service's own `Presets.xml` for that device is untouched and still shows the correct presets.
- The affected speaker is one of several devices under the **same** Marge account — for example a separate on-device AfterTouch instance per speaker, or several physical speakers migrated to one shared account.
- Clicking **Sync** in the Admin UI can also lose presets, but since v0.129.0 that path shows a confirmation warning before it overwrites anything destructively — that's a different, already-fixed issue (a stale-snapshot overwrite guard), not the reboot behavior described here.
**Cause:**
Not fully root-caused — this is firmware-internal. A byte-exact capture of the speaker's own `/full` request confirmed AfterTouch serves the correct preset data at the exact moment of the reboot-triggered resync; the wipe happens *after* that, entirely inside the speaker's own firmware callback chain, with no further network exchange to intercept from the service side. The trigger correlates with the **number of devices** listed under the account, not the account ID itself: removing the other devices from the account fixed it for one reporter, while changing only the account ID (with the other devices still present) did not. This isn't a universal shared-account problem either — a setup using a distinct account ID per speaker, with discovery left enabled, has not reproduced it — so treat this as an observed correlation, not a proven mechanism. See [issue #614](https://github.com/gesellix/Bose-SoundTouch/issues/614) for the full debugging history.
**Workaround (confirmed working, root cause still open):**
1. Admin UI → **Settings** → disable **"Enable Periodic Discovery"** first. Order matters — leaving it on lets a background sweep re-add a device you just removed, mid-cleanup.
2. Admin UI → **Devices** tab → click **✕** to remove every other device from the account, leaving only the speaker you're troubleshooting.
3. Reboot the speaker and confirm the presets survive.
This is fully reversible: re-enabling discovery brings the other devices back as harmless entries, and it doesn't touch their own presets/recents.
If you'd rather not change device-list membership, the Health tab's **"Restore presets to speaker"** QuickFix pushes the service's stored presets back onto the speaker without a reboot — a workaround for the symptom rather than the trigger, but useful if you hit this again before removing devices.
### ❌ Changing Target Domain in Settings doesn't change what a speaker actually uses {#settings-vs-migrate}
**Symptoms:**
+7
View File
@@ -7,6 +7,7 @@
"name": "@gesellix/bose-soundtouch",
"license": "MIT",
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.8"
},
@@ -14,6 +15,12 @@
"node": ">=24.0.0"
}
},
"node_modules/es-module-shims": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.4.tgz",
"integrity": "sha512-ea5srn5L89PWVad6Qle6r2kg+HvvLiL/GHqgNx06eFrkERHkrTFWaqo1w8Sd+XI3Xr0iAG5x3ZQ7mQ/hnQzNpA==",
"license": "MIT"
},
"node_modules/htm": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
+1
View File
@@ -9,6 +9,7 @@
"node": ">=24.0.0"
},
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.8"
}
+12 -4
View File
@@ -1428,10 +1428,18 @@ func (c *Client) GetZoneMembers() ([]string, error) {
// An empty <group/> response is reported as a zero-value Group; callers can
// distinguish with (*Group).IsEmpty().
//
// ST-10 is the only product that supports stereo pairs; on other devices
// the call is harmless but will always return an empty group. The endpoint
// is named /getGroup on the device (mirroring /getZone), even though some
// third-party wikis document it as plain /group.
// ST-10 is the only product that supports stereo pairs. Verified against
// real hardware: a SoundTouch 20 does not reply to /getGroup promptly. The
// device's own firmware ("AllegroWebserver") eventually answers with a
// plain-text "AllegroWebserver timeout: /getGroup" error body after an
// internal delay exceeding several seconds, but well within the client's
// own timeout (30s by default, see DefaultConfig) the request just looks
// like it never replied at all. Callers on a poll cycle must gate this call
// behind a stereo-pair-capable model check (see stereoPairCapable in
// pkg/service/soundtouchweb) instead of relying on a fast, harmless
// response on unsupported models. The endpoint is named /getGroup on the
// device (mirroring /getZone), even though some third-party wikis document
// it as plain /group.
func (c *Client) GetGroup() (*models.Group, error) {
var g models.Group
+41 -1
View File
@@ -1,6 +1,9 @@
package models
import "encoding/xml"
import (
"encoding/xml"
"strings"
)
// Group represents a stereo pair of two ST10 SoundTouch speakers.
type Group struct {
@@ -32,3 +35,40 @@ type GroupRole struct {
Role string `xml:"role"`
IPAddress string `xml:"ipAddress,omitempty"`
}
// SameGroup reports whether left and right describe the same stereo-pair
// configuration, comparing role assignments by device ID rather than by
// slice order. The device's own /getGroup response and its groupUpdated
// WebSocket event both populate Roles.Roles directly from XML unmarshaling
// in wire order, so a polled read and a pushed event for the identical pair
// are not guaranteed to list roles in the same order -- comparing with
// reflect.DeepEqual (order-sensitive) would then report a spurious change
// even though nothing about the pair actually changed. Two nil Groups are
// equal; exactly one nil is not.
func SameGroup(left, right *Group) bool {
if left == nil && right == nil {
return true
}
if left == nil || right == nil {
return false
}
if left.ID != right.ID || left.MasterDeviceID != right.MasterDeviceID ||
len(left.Roles.Roles) != len(right.Roles.Roles) {
return false
}
rightRoles := make(map[string]string, len(right.Roles.Roles))
for _, role := range right.Roles.Roles {
rightRoles[strings.TrimSpace(role.DeviceID)] = strings.ToUpper(strings.TrimSpace(role.Role))
}
for _, role := range left.Roles.Roles {
if rightRoles[strings.TrimSpace(role.DeviceID)] != strings.ToUpper(strings.TrimSpace(role.Role)) {
return false
}
}
return true
}
+30 -26
View File
@@ -211,37 +211,41 @@ func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSecti
}
itemType, _ := m["type"].(string)
if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
name, _ := m["text"].(string)
section := models.BmxNavSection{
Name: name,
Items: make([]models.BmxNavItem, 0, len(children)),
}
for _, child := range children {
cm, ok := child.(map[string]interface{})
if !ok {
continue
}
childType, _ := cm["type"].(string)
if childType == "audio" {
section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
} else {
section.Items = append(section.Items, tuneInNavigateLink(cm))
}
}
sections = append(sections, section)
continue
}
switch itemType {
case "link":
if children, ok := m["children"].([]interface{}); ok && len(children) > 0 {
name, _ := m["text"].(string)
section := models.BmxNavSection{
Name: name,
Items: make([]models.BmxNavItem, 0, len(children)),
}
for _, child := range children {
cm, ok := child.(map[string]interface{})
if !ok {
continue
}
childType, _ := cm["type"].(string)
if childType == "audio" {
section.Items = append(section.Items, tuneInNavigatePlayItem(cm))
} else {
section.Items = append(section.Items, tuneInNavigateLink(cm))
}
}
sections = append(sections, section)
} else {
topItems = append(topItems, tuneInNavigateLink(m))
}
topItems = append(topItems, tuneInNavigateLink(m))
case "audio":
topItems = append(topItems, tuneInNavigatePlayItem(m))
case "text":
// Ignore info text
// ignore
}
}
+68
View File
@@ -2,10 +2,78 @@ package bmx
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestTuneInSectionsAshx_UntypedContainerSurfacesStations is a regression
// test for a real-world bug: TuneIn's Browse.ashx?render=json responses
// often wrap the actual stations for a category in a container object that
// has "children" but no "type" field at all (unlike navigable sub-categories,
// which are always type:"link"). The original parser's switch only ever
// extracted "children" when itemType == "link", so these untyped containers
// -- and every station nested inside them -- were silently dropped: browse
// showed only category links, never any actual stations. Reproduces the
// shape of a real captured Jazz-genre browse response.
func TestTuneInSectionsAshx_UntypedContainerSurfacesStations(t *testing.T) {
const wantStationName = "SmoothJazz.com.pl (Poland)"
payload := `{
"head": {"status": "200", "title": "Jazz"},
"body": [
{
"text": "Stations",
"key": "stations",
"children": [
{
"type": "audio",
"text": "` + wantStationName + `",
"URL": "http://opml.radiotime.com/Tune.ashx?id=s106565",
"guide_id": "s106565",
"subtext": "Smooth Jazz"
}
]
}
]
}`
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(payload))
}))
defer ts.Close()
parsed, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("could not parse test server URL: %v", err)
}
allowedTuneInHosts[parsed.Hostname()] = true
defer delete(allowedTuneInHosts, parsed.Hostname())
sections, err := tuneInSectionsAshx(ts.URL, nil)
if err != nil {
t.Fatalf("tuneInSectionsAshx returned error: %v", err)
}
for _, section := range sections {
for _, item := range section.Items {
if item.Name == wantStationName {
if item.Links == nil || item.Links.BmxPlayback == nil {
t.Errorf("station %q was surfaced but has no BmxPlayback link: %+v", wantStationName, item)
}
return
}
}
}
t.Fatalf("expected station %q to be surfaced from the untyped container, got sections: %+v", wantStationName, sections)
}
func TestTuneInRenderJSONURI(t *testing.T) {
tests := []struct {
name string
+90 -5
View File
@@ -203,6 +203,9 @@ func TestHandleTuneInToken(t *testing.T) {
ts := httptest.NewServer(r)
defer ts.Close()
// Even when the speaker presents a refresh_token from a prior session,
// the handler always mints its own token rather than echoing the input
// back verbatim.
payload := `{"grant_type":"refresh_token","refresh_token":"test-refresh-token"}`
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
if err != nil {
@@ -214,16 +217,98 @@ func TestHandleTuneInToken(t *testing.T) {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]string
var resp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["access_token"] != "test-refresh-token" {
t.Errorf("Expected access_token 'test-refresh-token', got %v", resp["access_token"])
accessToken, _ := resp["access_token"].(string)
refreshToken, _ := resp["refresh_token"].(string)
if accessToken == "" {
t.Error("Expected a non-empty access_token")
}
if resp["refresh_token"] != "test-refresh-token" {
t.Errorf("Expected refresh_token 'test-refresh-token', got %v", resp["refresh_token"])
if refreshToken == "" {
t.Error("Expected a non-empty refresh_token")
}
if accessToken != refreshToken {
t.Errorf("Expected access_token and refresh_token to match, got %q and %q", accessToken, refreshToken)
}
if accessToken == "test-refresh-token" {
t.Error("Expected a minted token, not an echo of the request's refresh_token")
}
embedded, ok := resp["_embedded"].(map[string]interface{})
if !ok {
t.Fatalf("Expected _embedded object in response, got %v", resp["_embedded"])
}
if _, ok := embedded["bmx_account"]; !ok {
t.Error("Expected _embedded.bmx_account in response")
}
}
// TestHandleTuneInToken_Bootstrap covers the real-world trigger of the
// original bug: a speaker's very first TUNEIN token request, made under
// authenticationModel.anonymousAccount (autoCreate: true), has no prior
// refresh_token to present at all. The old handler echoed back whatever
// (possibly empty/absent) refresh_token it received, so this exact request
// used to round-trip an empty token and the speaker would reject every
// subsequent TUNEIN ContentItem selection with INVALID_SOURCE — even though
// browse and search worked fine and the same stream URL played successfully
// via Play URL/LOCAL_INTERNET_RADIO.
func TestHandleTuneInToken_Bootstrap(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
payload := `{"grant_type":"refresh_token"}`
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Errorf("Expected status 200, got %v", res.Status)
}
var resp map[string]interface{}
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
accessToken, _ := resp["access_token"].(string)
refreshToken, _ := resp["refresh_token"].(string)
if accessToken == "" {
t.Error("Bootstrap request (no refresh_token) must still receive a non-empty access_token")
}
if refreshToken == "" {
t.Error("Bootstrap request (no refresh_token) must still receive a non-empty refresh_token")
}
}
// TestHandleTuneInToken_MalformedBodyRejected covers the request-validation
// path that stayed in place alongside the unconditional-mint fix: a body
// that isn't even valid JSON is not a normal bootstrap call (which is still
// well-formed JSON, just with an empty/absent refresh_token — see
// TestHandleTuneInToken_Bootstrap), so it should be rejected rather than
// silently minting a token anyway.
func TestHandleTuneInToken_MalformedBodyRejected(t *testing.T) {
r, _ := setupRouter("http://localhost:8001", nil)
ts := httptest.NewServer(r)
defer ts.Close()
res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader("not json"))
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status 400 for a malformed body, got %v", res.Status)
}
}
+37 -7
View File
@@ -13,6 +13,7 @@ import (
"strings"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
@@ -118,8 +119,32 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ
}
}
// HandleTuneInToken returns a TuneIn access token.
// HandleTuneInToken returns an anonymous TuneIn access token.
//
// The registry advertises TUNEIN with authenticationModel.anonymousAccount
// (autoCreate: true) — see bmx_services.json — so the speaker's very first
// call here is a bootstrap request with no prior refresh_token to present.
// This handler used to echo back whatever refresh_token the speaker sent
// (mirroring an authenticated-refresh recording), which meant that very
// first bootstrap call round-tripped an empty token. The speaker never
// obtained a usable TuneIn account and subsequently rejected every TUNEIN
// ContentItem selection with INVALID_SOURCE, even though /sources reported
// TUNEIN as READY (READY only reflects registry presence, not a live
// account). Match HandleOrionToken's unconditional-generation shape
// instead: always mint a token, regardless of what the speaker sent.
//
// The token itself is a stable, constant value (datastore.GenerateSerialSecret
// is a pure function of the hardcoded "tunein" literal), not a fresh or
// per-device secret — it's the same value for every device and every call.
// That's fine today only because the Authorization gate is disabled for all
// TuneIn handlers (see HandleTuneInReport below) and nothing validates the
// token's uniqueness; if either of those ever changes, this would need a
// real per-device/per-session token instead.
func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
// The unconditional mint above means we never use the decoded values,
// but we still decode the body so a genuinely malformed request (not a
// normal bootstrap call, which is valid JSON with an empty/absent
// refresh_token) gets a 400 instead of silently succeeding.
var req struct {
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
@@ -130,18 +155,23 @@ func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) {
return
}
// For now, we return the provided refresh_token as access_token and refresh_token,
// mirroring the behavior seen in the recordings.
resp := map[string]string{
"access_token": req.RefreshToken,
"refresh_token": req.RefreshToken,
token := datastore.GenerateSerialSecret("tunein")
resp := map[string]interface{}{
"_embedded": map[string]interface{}{
"bmx_account": map[string]string{
"displayName": "",
"username": "",
},
},
"access_token": token,
"refresh_token": token,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
-1
View File
@@ -1661,7 +1661,6 @@ async function fetchInteractions() {
const path = i.path || i.Path || "";
const status = i.status || i.Status || "";
const category = i.category || i.Category || "";
const session = i.session || i.Session || "";
const file = i.file || i.File || "";
const scmudcData = i.scmudc_data || i.SCMUDCData || null;
@@ -0,0 +1,137 @@
//go:build browsertest
// Package soundtouchweb browser-level regression tests for #649. These drive
// a real headless Chrome via chromedp (already a project dependency, used
// today for the doc-screenshot tool) instead of only asserting on the raw
// HTML/JS source. They are opt-in (build tag "browsertest", run via `make
// test-browser`) rather than part of the default `go test ./...`/`make
// check` path, since they require a Chrome/Chromium binary to be present --
// see CONTRIBUTING or the Makefile for how to run them locally or in CI.
package soundtouchweb
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/chromedp/chromedp"
"github.com/go-chi/chi/v5"
)
// newHeadlessChromeContext returns a context bound to a fresh headless
// Chrome instance, torn down automatically at the end of the test.
func newHeadlessChromeContext(t *testing.T) context.Context {
t.Helper()
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
// CI runners commonly execute as a user without the namespace
// permissions Chrome's sandbox needs; harmless to also set
// locally.
chromedp.Flag("no-sandbox", true),
)...,
)
t.Cleanup(cancelAlloc)
ctx, cancelCtx := chromedp.NewContext(allocCtx)
t.Cleanup(cancelCtx)
ctx, cancelTimeout := context.WithTimeout(ctx, 30*time.Second)
t.Cleanup(cancelTimeout)
return ctx
}
// TestPlayerRendersNatively confirms the shipped page (native import maps,
// es-module-shims left uninjected) still renders in an ordinary modern
// browser -- i.e. that restoring import maps for #649 didn't break the
// common case for the vast majority of users who never need the shim.
func TestPlayerRendersNatively(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.Mount(r, nil)
server := httptest.NewServer(r)
t.Cleanup(server.Close)
ctx := newHeadlessChromeContext(t)
var shimInjected bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/app"),
chromedp.WaitVisible(`.nav-discover-icon`, chromedp.ByQuery),
chromedp.Evaluate(`document.querySelectorAll('script[src*="es-module-shims"]').length > 0`, &shimInjected),
); err != nil {
t.Fatalf("chromedp run: %v", err)
}
if shimInjected {
t.Error("es-module-shims should not be injected on a browser with native import map support")
}
}
// TestPlayerRendersUnderForcedShimMode exercises es-module-shims resolving the
// same import map and vendored files the real app uses. It does not emulate
// Safari or the production feature-detection loader; those require a target-
// browser canary. The test serves a page that forces es-module-shims into
// shimMode
// (see the library's README: shimMode is triggered by
// window.esmsInitOptions.shimMode or by using importmap-shim/module-shim
// script types), which routes every browser -- including this ordinary
// headless Chrome -- through the library's own polyfill resolution instead
// of native import map support.
func TestPlayerRendersUnderForcedShimMode(t *testing.T) {
app := NewWebApp()
r := chi.NewRouter()
app.MountWeb(r, nil) // only need /app/static/* and /api/control/*
const shimModePage = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>window.esmsInitOptions = { shimMode: true };</script>
<script src="/app/static/lib/es-module-shims.js"></script>
<script type="importmap-shim">
{
"imports": {
"preact": "/app/static/lib/preact.module.js",
"preact/hooks": "/app/static/lib/preact-hooks.module.js",
"htm": "/app/static/lib/htm.module.js"
}
}
</script>
<link rel="stylesheet" href="/app/static/css/app.css" />
</head>
<body>
<div id="app"></div>
<script type="module-shim" src="/app/static/js/app.js"></script>
</body>
</html>`
r.Get("/test-shim-mode", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(shimModePage))
})
server := httptest.NewServer(r)
t.Cleanup(server.Close)
ctx := newHeadlessChromeContext(t)
var rendered bool
if err := chromedp.Run(ctx,
chromedp.Navigate(server.URL+"/test-shim-mode"),
chromedp.WaitVisible(`.nav-discover-icon`, chromedp.ByQuery),
chromedp.Evaluate(`document.getElementById('app').children.length > 0`, &rendered),
); err != nil {
t.Fatalf("chromedp run (forced shim mode): %v", err)
}
if !rendered {
t.Error("app did not render under forced es-module-shims shim mode")
}
}
@@ -0,0 +1,290 @@
package soundtouchweb
import (
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// deviceView is the player-facing representation of one control target.
// A stereo pair is projected as one target keyed by its master speaker's host;
// the underlying registry continues to track both physical speakers.
type deviceView struct {
Info *models.DeviceInfo `json:"info"`
Status *webtypes.DeviceStatus `json:"status"`
LastSeen time.Time `json:"lastSeen"`
StereoPair *stereoPairView `json:"stereoPair,omitempty"`
}
// deviceProjectionEntry captures one immutable status pointer per physical
// device. Projection must not re-read live connection state midway through
// building a response, otherwise group membership and the emitted status can
// describe different moments.
type deviceProjectionEntry struct {
ID string
Info *models.DeviceInfo
Status *webtypes.DeviceStatus
LastSeen time.Time
}
// stereoPairView describes the physical members represented by a logical
// player target. Controls are always sent to MasterDeviceID via the map key.
type stereoPairView struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
MasterDeviceID string `json:"masterDeviceId"`
Status string `json:"status,omitempty"`
MemberCount int `json:"memberCount"`
AvailableMemberCount int `json:"availableMemberCount"`
Degraded bool `json:"degraded"`
Members []stereoPairMemberView `json:"members"`
}
// stereoPairMemberView is the player-facing role and availability of one
// physical speaker in a stereo pair.
type stereoPairMemberView struct {
DeviceID string `json:"deviceId"`
Role string `json:"role"`
IPAddress string `json:"ipAddress,omitempty"`
Name string `json:"name,omitempty"`
Available bool `json:"available"`
}
// stereoPairCapable reports whether info's model supports stereo pairing
// (ST-10 only). This must stay a model-name check rather than a runtime
// capability probe: verified against real hardware, a SoundTouch 20 lists
// /getGroup (and /addGroup, /removeGroup, /updateGroup) in its own
// /supportedURLs response even though the device doesn't actually reply to
// /getGroup -- see Client.GetGroup's doc comment. The device's supportedURLs
// listing reflects firmware-level route registration, not per-model feature
// support, so it cannot be used to detect stereo-pair capability either.
func stereoPairCapable(info *models.DeviceInfo) bool {
if info == nil {
return false
}
typeName := strings.ToLower(strings.TrimSpace(info.Type))
return typeName == "st10" || typeName == "soundtouch 10"
}
// deviceViewSnapshot projects the physical registry into logical control
// targets for the HTTP API and the global player WebSocket.
func (app *WebApp) deviceViewSnapshot() map[string]deviceView {
return projectDeviceEntries(app.DeviceSnapshot())
}
func projectDeviceEntries(snapshot []DeviceEntry) map[string]deviceView {
return projectCapturedDeviceEntries(captureDeviceProjectionEntries(snapshot))
}
func captureDeviceProjectionEntries(snapshot []DeviceEntry) []deviceProjectionEntry {
captured := make([]deviceProjectionEntry, 0, len(snapshot))
for _, entry := range snapshot {
if entry.Device == nil {
continue
}
captured = append(captured, deviceProjectionEntry{
ID: entry.ID,
Info: entry.Device.DeviceInfo,
Status: entry.Device.Status(),
LastSeen: entry.LastSeen,
})
}
return captured
}
func projectCapturedDeviceEntries(snapshot []deviceProjectionEntry) map[string]deviceView {
byDeviceID := make(map[string][]deviceProjectionEntry, len(snapshot))
for _, entry := range snapshot {
if entry.Info == nil {
continue
}
deviceID := strings.TrimSpace(entry.Info.DeviceID)
if deviceID != "" {
byDeviceID[deviceID] = append(byDeviceID[deviceID], entry)
}
}
masters := make(map[string]*stereoPairView)
hidden := make(map[string]bool)
for _, entry := range snapshot {
if entry.Info == nil {
continue
}
if entry.Status == nil || !validMasterGroup(entry.Info.DeviceID, entry.Status.Group) {
continue
}
master, unique := uniqueDeviceEntry(byDeviceID, entry.Status.Group.MasterDeviceID)
if !unique || master.ID != entry.ID || !registeredMembersAgree(entry.Status.Group, byDeviceID) {
continue
}
pair := newStereoPairView(entry.Status.Group, byDeviceID)
masters[entry.ID] = pair
for _, role := range entry.Status.Group.Roles.Roles {
member, ok := uniqueDeviceEntry(byDeviceID, role.DeviceID)
if ok && member.ID != entry.ID {
hidden[member.ID] = true
}
}
}
devices := make(map[string]deviceView, len(snapshot))
for _, entry := range snapshot {
if hidden[entry.ID] {
continue
}
pair := masters[entry.ID]
devices[entry.ID] = deviceView{
Info: projectedDeviceInfo(entry.Info, pair),
Status: entry.Status,
LastSeen: entry.LastSeen,
StereoPair: pair,
}
}
return devices
}
func validMasterGroup(deviceID string, group *models.Group) bool {
if group == nil || group.IsEmpty() || strings.TrimSpace(group.ID) == "" ||
strings.TrimSpace(group.MasterDeviceID) == "" || len(group.Roles.Roles) != 2 ||
strings.TrimSpace(deviceID) != strings.TrimSpace(group.MasterDeviceID) {
return false
}
seenDevices := make(map[string]bool, len(group.Roles.Roles))
seenRoles := make(map[string]bool, len(group.Roles.Roles))
masterPresent := false
for _, role := range group.Roles.Roles {
memberID := strings.TrimSpace(role.DeviceID)
memberRole := strings.ToUpper(strings.TrimSpace(role.Role))
if memberID == "" || seenDevices[memberID] || (memberRole != "LEFT" && memberRole != "RIGHT") || seenRoles[memberRole] {
return false
}
seenDevices[memberID] = true
seenRoles[memberRole] = true
masterPresent = masterPresent || memberID == strings.TrimSpace(group.MasterDeviceID)
}
return masterPresent && seenRoles["LEFT"] && seenRoles["RIGHT"]
}
func uniqueDeviceEntry(byDeviceID map[string][]deviceProjectionEntry, deviceID string) (deviceProjectionEntry, bool) {
entries := byDeviceID[strings.TrimSpace(deviceID)]
if len(entries) != 1 {
return deviceProjectionEntry{}, false
}
return entries[0], true
}
func registeredMembersAgree(group *models.Group, byDeviceID map[string][]deviceProjectionEntry) bool {
for _, role := range group.Roles.Roles {
entries := byDeviceID[strings.TrimSpace(role.DeviceID)]
if len(entries) > 1 {
return false
}
if len(entries) == 0 {
continue
}
if entries[0].Status == nil || !models.SameGroup(group, entries[0].Status.Group) {
return false
}
}
return true
}
func newStereoPairView(group *models.Group, byDeviceID map[string][]deviceProjectionEntry) *stereoPairView {
members := make([]stereoPairMemberView, 0, len(group.Roles.Roles))
available := 0
for _, role := range group.Roles.Roles {
member := stereoPairMemberView{
DeviceID: role.DeviceID,
Role: role.Role,
IPAddress: role.IPAddress,
}
if entry, ok := uniqueDeviceEntry(byDeviceID, role.DeviceID); ok {
if entry.Info != nil {
member.Name = entry.Info.Name
if entry.Info.IPAddress != "" {
member.IPAddress = entry.Info.IPAddress
}
}
member.Available = entry.Status != nil && entry.Status.IsConnected
if member.Available {
available++
}
}
members = append(members, member)
}
return &stereoPairView{
ID: group.ID,
Name: logicalPairName(group.Name, members),
MasterDeviceID: group.MasterDeviceID,
Status: group.Status,
MemberCount: len(members),
AvailableMemberCount: available,
Degraded: available != len(members) || (group.Status != "" && group.Status != "GROUP_OK"),
Members: members,
}
}
func projectedDeviceInfo(info *models.DeviceInfo, pair *stereoPairView) *models.DeviceInfo {
if info == nil || pair == nil || pair.Name == "" || pair.Name == info.Name {
return info
}
projected := *info
projected.Name = pair.Name
return &projected
}
func logicalPairName(groupName string, members []stereoPairMemberView) string {
commonName := ""
for _, member := range members {
name := strings.TrimSpace(member.Name)
if name == "" {
return groupName
}
if commonName == "" {
commonName = name
continue
}
if !strings.EqualFold(commonName, name) {
return groupName
}
}
if commonName != "" {
return commonName
}
return groupName
}
@@ -0,0 +1,249 @@
package soundtouchweb
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
func projectionDevice(host, deviceID, name string, connected bool, group *models.Group) DeviceEntry {
conn := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{
DeviceID: deviceID,
Name: name,
IPAddress: host,
})
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: connected, Group: group})
return DeviceEntry{ID: host, Device: conn, LastSeen: conn.LastSeen}
}
func testStereoGroup() *models.Group {
return &models.Group{
ID: "pair-1",
Name: "Living Room + Living Room",
MasterDeviceID: "left-id",
Status: "GROUP_OK",
Roles: models.GroupRoles{Roles: []models.GroupRole{
{DeviceID: "left-id", Role: "LEFT", IPAddress: "192.0.2.10"},
{DeviceID: "right-id", Role: "RIGHT", IPAddress: "192.0.2.11"},
}},
}
}
func TestProjectDeviceEntriesCollapsesStereoPairUnderMaster(t *testing.T) {
group := testStereoGroup()
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
})
if len(got) != 1 {
t.Fatalf("projected devices = %d, want one logical stereo target: %+v", len(got), got)
}
master, ok := got["192.0.2.10"]
if !ok {
t.Fatalf("master control target missing: %+v", got)
}
if master.StereoPair == nil {
t.Fatal("master is missing stereo-pair metadata")
}
if master.StereoPair.MemberCount != 2 || master.StereoPair.AvailableMemberCount != 2 || master.StereoPair.Degraded {
t.Errorf("unexpected pair availability: %+v", master.StereoPair)
}
if master.Info.Name != "Living Room" || master.StereoPair.Name != "Living Room" {
t.Errorf("logical pair name was not projected consistently: %+v", master)
}
if _, ok := got["192.0.2.11"]; ok {
t.Error("physical right member must not be a second control target")
}
}
func TestProjectDeviceEntriesShowsDegradedPairWhenMemberIsMissing(t *testing.T) {
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, testStereoGroup()),
})
pair := got["192.0.2.10"].StereoPair
if pair == nil {
t.Fatal("connected master should remain a logical pair when its member is unavailable")
}
if pair.AvailableMemberCount != 1 || !pair.Degraded {
t.Errorf("missing member not reflected as degraded: %+v", pair)
}
}
func TestProjectDeviceEntriesKeepsStablePairWhenMasterIsDisconnected(t *testing.T) {
group := testStereoGroup()
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", false, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
})
if len(got) != 1 {
t.Fatalf("projected devices = %d, want a stable logical pair while its master is registered", len(got))
}
pair := got["192.0.2.10"].StereoPair
if pair == nil || !pair.Degraded || pair.AvailableMemberCount != 1 {
t.Errorf("disconnected master should produce a degraded logical pair: %+v", got)
}
}
func TestProjectDeviceEntriesLeavesMemberPhysicalWhenMasterIsAbsent(t *testing.T) {
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.11", "right-id", "Living Room", true, testStereoGroup()),
})
if len(got) != 1 || got["192.0.2.11"].StereoPair != nil {
t.Fatalf("member without a registered master must remain a physical target: %+v", got)
}
}
func TestProjectDeviceEntriesRequiresMasterReportedGroup(t *testing.T) {
group := testStereoGroup()
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, nil),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
})
if len(got) != 2 {
t.Fatalf("slave-only group data must not collapse the registry: %+v", got)
}
}
func TestProjectDeviceEntriesRejectsMalformedGroup(t *testing.T) {
group := testStereoGroup()
group.Roles.Roles[1].DeviceID = group.Roles.Roles[0].DeviceID
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
})
if len(got) != 2 {
t.Fatalf("malformed pair must not hide a physical device: %+v", got)
}
}
func TestProjectDeviceEntriesRejectsConflictingMemberClaim(t *testing.T) {
masterGroup := testStereoGroup()
memberGroup := testStereoGroup()
memberGroup.ID = "different-pair"
got := projectDeviceEntries([]DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, masterGroup),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, memberGroup),
})
if len(got) != 2 {
t.Fatalf("conflicting pair claims must fail open: %+v", got)
}
}
func TestProjectCapturedDeviceEntriesUsesOneCoherentStatusPerDevice(t *testing.T) {
group := testStereoGroup()
entries := []DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
}
captured := captureDeviceProjectionEntries(entries)
entries[0].Device.ApplyGroupEvent(&models.Group{}, time.Now())
entries[1].Device.ApplyGroupEvent(&models.Group{}, time.Now())
got := projectCapturedDeviceEntries(captured)
master := got["192.0.2.10"]
if master.StereoPair == nil || master.Status == nil || master.Status.Group == nil || master.Status.Group.ID != "pair-1" {
t.Fatalf("captured projection mixed newer connection state into its response: %+v", got)
}
if fresh := projectDeviceEntries(entries); len(fresh) != 2 {
t.Fatalf("fresh projection did not observe the cleared group: %+v", fresh)
}
}
func TestDeviceViewSnapshotConcurrentTouchUsesCapturedLastSeen(t *testing.T) {
app := NewWebApp()
conn := newRegistryDevice("Living Room")
if !app.AddDevice("192.0.2.10", conn) {
t.Fatal("AddDevice returned false on first insert")
}
stale := app.DeviceSnapshot()
if len(stale) != 1 {
t.Fatalf("DeviceSnapshot len = %d, want 1", len(stale))
}
if !app.TouchDevice("192.0.2.10") {
t.Fatal("TouchDevice returned false for registered device")
}
if got := projectDeviceEntries(stale)["192.0.2.10"].LastSeen; got != stale[0].LastSeen {
t.Fatalf("projection LastSeen = %s, want captured value %s", got, stale[0].LastSeen)
}
const iterations = 1000
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
<-start
for i := 0; i < iterations; i++ {
app.TouchDevice("192.0.2.10")
}
}()
go func() {
defer wg.Done()
<-start
for i := 0; i < iterations; i++ {
_ = app.deviceViewSnapshot()
}
}()
close(start)
wg.Wait()
}
func TestHandleAPIDevicesUsesLogicalStereoProjection(t *testing.T) {
app := NewWebApp()
group := testStereoGroup()
for _, entry := range []DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
} {
app.AddDevice(entry.ID, entry.Device)
}
response := httptest.NewRecorder()
app.HandleAPIDevices(response, httptest.NewRequest("GET", "/api/control/devices", nil))
var payload struct {
Success bool `json:"success"`
Data map[string]deviceView `json:"data"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatalf("decode devices response: %v", err)
}
if response.Code != http.StatusOK || !payload.Success || len(payload.Data) != 1 {
t.Fatalf("unexpected devices response: status=%d payload=%+v", response.Code, payload)
}
if pair := payload.Data["192.0.2.10"].StereoPair; pair == nil || pair.ID != "pair-1" || pair.MemberCount != 2 {
t.Fatalf("logical stereo metadata missing from devices API: %+v", payload.Data)
}
}
+216 -11
View File
@@ -3,6 +3,7 @@ package soundtouchweb
import (
"context"
"log"
"strings"
"sync"
"time"
@@ -10,12 +11,18 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
)
// NewDiscoveryService loads config and returns a unified discovery service
// preconfigured for the web UI's use (10 s discovery timeout, cache on).
// When discoveryInterface is non-empty, mDNS/UPnP are pinned to that NIC.
func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryService {
// configuredHosts (e.g. from --devices) are folded into cfg.PreferredDevices
// alongside any already loaded from PREFERRED_DEVICES (deduplicated by
// host), so they're retried on every subsequent DiscoverDevices pass, not
// just once at startup -- a host that's offline now still gets picked up
// once it comes online.
func NewDiscoveryService(discoveryInterface string, configuredHosts ...string) *discovery.UnifiedDiscoveryService {
cfg, err := config.LoadFromEnv()
if err != nil {
log.Printf("Failed to load config: %v, using defaults", err)
@@ -30,6 +37,23 @@ func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryS
cfg.DiscoveryInterface = discoveryInterface
}
existingHosts := make(map[string]bool, len(cfg.PreferredDevices))
for _, d := range cfg.PreferredDevices {
existingHosts[d.Host] = true
}
for _, host := range configuredHosts {
if host == "" || existingHosts[host] {
continue
}
cfg.PreferredDevices = append(cfg.PreferredDevices, config.DeviceConfig{
Host: host,
Port: speaker.HTTPPort,
})
existingHosts[host] = true
}
return discovery.NewUnifiedDiscoveryService(cfg)
}
@@ -40,9 +64,13 @@ func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryS
// mDNS/UPnP. If the host is already known, the existing entry's
// LastSeen is bumped and the function returns without re-fetching.
func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
app.addDeviceByHost(host, port, source)
}
func (app *WebApp) addDeviceByHost(host string, port int, source string) *webtypes.DeviceConnection {
// Fast path: skip the network call if we already know this host.
if app.TouchDevice(host) {
return
return nil
}
c := client.NewClient(&client.Config{
@@ -54,7 +82,7 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
info, err := c.GetDeviceInfo()
if err != nil {
log.Printf("Failed to fetch device info from %s (%s): %v", sanitizeLog(host), sanitizeLog(source), err)
return
return nil
}
// Ensure IPAddress is set for the web UI
@@ -67,7 +95,7 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
// Lost a race — another goroutine inserted the same host
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
// on the existing entry; discard our conn.
return
return nil
}
go app.UpdateDeviceStatus(host, conn)
@@ -90,10 +118,13 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
}()
log.Printf("Added %s device %s (%s) at %s:%d", sanitizeLog(source), sanitizeLog(info.Name), sanitizeLog(info.Type), sanitizeLog(host), port)
return conn
}
// SeedExtraDevices registers any devices reported by the ExtraDeviceHosts hook
// (if set) via AddDeviceByHost. Idempotent: already-known hosts are skipped.
// (if set) via AddDeviceByHost, and prunes any previously-seeded host that no
// longer appears in that set. Idempotent: already-known hosts are skipped.
// Used by the embedded build to surface the service datastore's devices even
// when network discovery is disabled; a no-op for standalone soundtouch-player.
//
@@ -102,15 +133,56 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
// datastore would otherwise stall the whole seed for 10 s, serially. Fanning
// out bounds the cost to roughly a single timeout regardless of how many
// devices are offline. AddDeviceByHost is registry-safe under concurrency.
//
// A hook read failure is logged and otherwise swallowed here; callers that
// need to distinguish "read failed" from "converged" (the bounded startup
// retry) should call seedExtraDevices directly instead.
func (app *WebApp) SeedExtraDevices() {
if _, _, _, err := app.seedExtraDevices(); err != nil {
log.Printf("SeedExtraDevices: failed to read extra device hosts: %v", err)
}
}
type seededExtraDevice struct {
host string
conn *webtypes.DeviceConnection
}
// seedExtraDevices probes any ExtraDeviceHosts hosts that aren't already
// registered, and prunes any registered host that's no longer in the current
// desired set. Pruning is safe to apply to the whole registry (not just hosts
// this call inserted) because ExtraDeviceHosts is the only inserter into this
// registry for the embedded build: discoveryService is nil there, so the
// mDNS/UPnP insertion path in DiscoverDevices is never reached.
//
// Runs are serialized via seedMu so the bounded startup retry loop
// (SeedExtraDevicesUntilReady) and a devices-changed-hook-triggered
// SeedExtraDevices call never issue concurrent probes to the same offline
// host.
//
// A non-nil error means the hook itself failed (e.g. a datastore glitch);
// callers must treat that as "unknown state, don't prune, don't declare
// ready" rather than as an empty desired set.
func (app *WebApp) seedExtraDevices() (inserted []seededExtraDevice, removed int, desired map[string]struct{}, err error) {
if app.ExtraDeviceHosts == nil {
return
return nil, 0, nil, nil
}
var wg sync.WaitGroup
app.seedMu.Lock()
defer app.seedMu.Unlock()
for _, host := range app.ExtraDeviceHosts() {
if host == "" {
desired, err = app.extraDeviceHostSet()
if err != nil {
return nil, 0, nil, err
}
var (
mu sync.Mutex
wg sync.WaitGroup
)
for host := range desired {
if _, ok := app.GetDevice(host); ok {
continue
}
@@ -119,11 +191,129 @@ func (app *WebApp) SeedExtraDevices() {
go func(h string) {
defer wg.Done()
app.AddDeviceByHost(h, 8090, "service-store")
conn := app.addDeviceByHost(h, 8090, "service-store")
if conn == nil {
return
}
mu.Lock()
inserted = append(inserted, seededExtraDevice{host: h, conn: conn})
mu.Unlock()
}(host)
}
wg.Wait()
for _, entry := range app.DeviceSnapshot() {
if _, ok := desired[entry.ID]; ok {
continue
}
if app.removeDeviceIfMatch(entry.ID, entry.Device) {
removed++
}
}
return inserted, removed, desired, nil
}
// SeedExtraDevicesUntilReady retries only the hosts returned by
// ExtraDeviceHosts until all of them have been registered or ctx expires. It
// does not run mDNS or UPnP discovery. This gives embedded deployments a
// bounded way to recover when their persisted speakers are not yet routable
// while the service is starting.
func (app *WebApp) SeedExtraDevicesUntilReady(ctx context.Context, retryInterval time.Duration) {
retryUntilReady(ctx, retryInterval, func() bool {
inserted, removed, desired, err := app.seedExtraDevices()
if err != nil {
log.Printf("SeedExtraDevicesUntilReady: failed to read extra device hosts, will retry: %v", err)
return false
}
if len(inserted) > 0 || removed > 0 {
app.BroadcastDeviceList()
}
return app.extraDeviceHostsPresent(desired)
})
}
func (app *WebApp) extraDeviceHosts() ([]string, error) {
if app.ExtraDeviceHosts == nil {
return nil, nil
}
rawHosts, err := app.ExtraDeviceHosts()
if err != nil {
return nil, err
}
hosts := make([]string, 0, len(rawHosts))
seen := make(map[string]struct{}, len(rawHosts))
for _, host := range rawHosts {
if host == "" {
continue
}
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
hosts = append(hosts, host)
}
return hosts, nil
}
func (app *WebApp) extraDeviceHostSet() (map[string]struct{}, error) {
hosts, err := app.extraDeviceHosts()
if err != nil {
return nil, err
}
desired := make(map[string]struct{}, len(hosts))
for _, host := range hosts {
desired[host] = struct{}{}
}
return desired, nil
}
func (app *WebApp) extraDeviceHostsPresent(desired map[string]struct{}) bool {
for host := range desired {
if _, ok := app.GetDevice(host); !ok {
return false
}
}
return true
}
func retryUntilReady(ctx context.Context, retryInterval time.Duration, attempt func() bool) {
for {
select {
case <-ctx.Done():
return
default:
}
if attempt() {
return
}
timer := time.NewTimer(retryInterval)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
// DiscoverDevices refreshes the device registry. When TriggerDiscovery is set
@@ -164,6 +354,21 @@ func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discov
log.Printf("Found %d devices", len(devices))
for _, device := range devices {
app.AddDeviceByHost(device.Host, device.Port, "discovered")
app.AddDeviceByHost(device.Host, device.Port, classifySource(device.DiscoveryMethod))
}
}
// classifySource labels a discovered device "manual" if it came from (at
// least in part) a configured host, "discovered" otherwise. discoveryMethod
// can be a "+"-joined composite (e.g. "Configuration+mDNS/Bonjour") when
// mergeDeviceData combines a configured host with the same device found via
// mDNS/UPnP in the same sweep -- match by substring, not exact equality, so
// a manually configured host that's also independently discoverable still
// gets labeled "manual".
func classifySource(discoveryMethod string) string {
if strings.Contains(discoveryMethod, "Configuration") {
return "manual"
}
return "discovered"
}
+324
View File
@@ -0,0 +1,324 @@
package soundtouchweb
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
func TestDiscoverDevicesRetriesConfiguredHosts(t *testing.T) {
var available atomic.Bool
var infoRequests atomic.Int32
// NewTestServer (Go 1.27) registers its own t.Cleanup(Close) instead of
// needing a manual defer, and fails the test on a handler panic. It
// defaults to an in-memory transport reachable only via Server.Client(),
// but our production client.NewClient dials a real address, so Start()
// (rather than Client()) is used here to get a real loopback listener,
// same as the old NewServer -- see https://pkg.go.dev/net/http/httptest#NewTestServer.
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/info" {
http.NotFound(w, r)
return
}
infoRequests.Add(1)
if !available.Load() {
http.Error(w, "offline", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Configured speaker</name><type>SoundTouch 10</type></info>`))
}))
server.Start()
t.Setenv("UPNP_ENABLED", "false")
t.Setenv("MDNS_ENABLED", "false")
t.Setenv("PREFERRED_DEVICES", "")
configuredHost := strings.TrimPrefix(server.URL, "http://")
discoveryService := NewDiscoveryService("", configuredHost)
app := NewWebApp()
app.DiscoverDevices(context.Background(), discoveryService)
if got := app.DeviceCount(); got != 0 {
t.Fatalf("device count after offline probe = %d, want 0", got)
}
available.Store(true)
app.DiscoverDevices(context.Background(), discoveryService)
if got := app.DeviceCount(); got != 1 {
t.Fatalf("device count after retry = %d, want 1", got)
}
if got := infoRequests.Load(); got != 2 {
t.Fatalf("/info request count = %d, want 2", got)
}
if !app.RemoveDevice(configuredHost) {
t.Fatal("configured device was not registered under its host")
}
// AddDeviceByHost spawns a one-shot status-update goroutine and a 30s-
// ticker poll loop on successful registration. RemoveDevice signals the
// ticker loop to exit via conn.Done() but doesn't wait for it to actually
// observe the close, and the one-shot goroutine has no cancellation at
// all. Give them a moment to finish before the deferred server.Close()
// runs, so a still-in-flight request against the closing httptest server
// doesn't produce log noise or -race flakiness.
time.Sleep(50 * time.Millisecond)
}
// TestClassifySource covers the case the test above can't reach without a
// real mDNS/UPnP sweep: mergeDeviceData joins discovery methods with "+"
// when a configured host is also found via mDNS/UPnP in the same pass (see
// pkg/discovery/unified.go), so DiscoveryMethod is not always exactly
// "Configuration" for a manually configured device.
func TestClassifySource(t *testing.T) {
tests := []struct {
discoveryMethod string
want string
}{
{"Configuration", "manual"},
{"Configuration+mDNS/Bonjour", "manual"},
{"mDNS/Bonjour+Configuration", "manual"},
{"mDNS/Bonjour", "discovered"},
{"SSDP/UPnP", "discovered"},
{"", "discovered"},
}
for _, tt := range tests {
if got := classifySource(tt.discoveryMethod); got != tt.want {
t.Errorf("classifySource(%q) = %q, want %q", tt.discoveryMethod, got, tt.want)
}
}
}
func TestRetryUntilReadyStopsAfterSuccess(t *testing.T) {
attempts := 0
retryUntilReady(context.Background(), time.Millisecond, func() bool {
attempts++
return attempts == 3
})
if attempts != 3 {
t.Fatalf("attempt count = %d, want 3", attempts)
}
}
func TestRetryUntilReadyStopsAfterContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
attempts := 0
retryUntilReady(ctx, time.Hour, func() bool {
attempts++
cancel()
return false
})
if attempts != 1 {
t.Fatalf("attempt count = %d, want 1", attempts)
}
}
func TestExtraDeviceHostsPresent(t *testing.T) {
app := NewWebApp()
app.ExtraDeviceHosts = func() ([]string, error) { return []string{"known", "", "known", "missing"}, nil }
app.AddDevice("known", &webtypes.DeviceConnection{})
desired, err := app.extraDeviceHostSet()
if err != nil {
t.Fatalf("extraDeviceHostSet() error = %v", err)
}
if len(desired) != 2 {
t.Fatalf("desired host count = %d, want 2", len(desired))
}
if app.extraDeviceHostsPresent(desired) {
t.Fatal("extraDeviceHostsPresent = true with a missing host")
}
app.AddDevice("missing", &webtypes.DeviceConnection{})
if !app.extraDeviceHostsPresent(desired) {
t.Fatal("extraDeviceHostsPresent = false with all hosts registered")
}
}
func TestExtraDeviceHostSetPropagatesHookError(t *testing.T) {
app := NewWebApp()
wantErr := errors.New("datastore glitch")
app.ExtraDeviceHosts = func() ([]string, error) { return nil, wantErr }
if _, err := app.extraDeviceHostSet(); !errors.Is(err, wantErr) {
t.Fatalf("extraDeviceHostSet() error = %v, want %v", err, wantErr)
}
}
func TestSeedExtraDevicesSkipsKnownHosts(t *testing.T) {
app := NewWebApp()
app.ExtraDeviceHosts = func() ([]string, error) { return []string{"known"}, nil }
lastSeen := time.Unix(123, 0)
conn := &webtypes.DeviceConnection{LastSeen: lastSeen}
app.AddDevice("known", conn)
app.SeedExtraDevices()
if !conn.LastSeen.Equal(lastSeen) {
t.Fatalf("known host LastSeen changed from %s to %s", lastSeen, conn.LastSeen)
}
}
// TestSeedExtraDevicesUntilReadyRetriesOnHookError covers the code-review
// finding that a hook error (e.g. a transient datastore read failure) must
// not be treated as "zero hosts persisted", which would make the readiness
// check trivially pass and end the retry window immediately.
func TestSeedExtraDevicesUntilReadyRetriesOnHookError(t *testing.T) {
var calls atomic.Int32
app := NewWebApp()
app.ExtraDeviceHosts = func() ([]string, error) {
n := calls.Add(1)
if n < 3 {
return nil, errors.New("datastore glitch")
}
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
app.SeedExtraDevicesUntilReady(ctx, time.Millisecond)
if got := calls.Load(); got < 3 {
t.Fatalf("hook call count = %d, want at least 3 (kept retrying past the errors)", got)
}
}
// TestSeedExtraDevicesPrunesHostRemovedAfterEarlierAttempt covers the
// code-review finding that pruning must consider the whole registry, not
// only hosts inserted during the current call: a host registered by an
// earlier seed call that later falls out of ExtraDeviceHosts must still be
// pruned by a later call, and this must hold for the plain SeedExtraDevices
// path too, not just the bounded retry loop.
func TestSeedExtraDevicesPrunesHostRemovedAfterEarlierAttempt(t *testing.T) {
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Stale speaker</name><type>SoundTouch 10</type></info>`))
}))
server.Start()
host := strings.TrimPrefix(server.URL, "http://")
var stillDesired atomic.Bool
stillDesired.Store(true)
app := NewWebApp()
app.ExtraDeviceHosts = func() ([]string, error) {
if stillDesired.Load() {
return []string{host}, nil
}
return nil, nil
}
// First call registers the host.
app.SeedExtraDevices()
if _, ok := app.GetDevice(host); !ok {
t.Fatalf("host %s was not registered on the first seed call", host)
}
// Simulate the device being removed from the datastore in between calls.
stillDesired.Store(false)
// A later plain SeedExtraDevices call (as triggered by
// SetDevicesChangedHook or the manual /api/control/discover route) must
// still prune it, not just the bounded retry loop.
app.SeedExtraDevices()
if _, ok := app.GetDevice(host); ok {
t.Fatal("stale host was not pruned by a later SeedExtraDevices call")
}
}
// TestSeedExtraDevicesSerializesConcurrentRuns covers the code-review finding
// that the bounded startup retry loop and a devices-changed-hook-triggered
// SeedExtraDevices call must not issue concurrent probes to the same
// still-offline host.
func TestSeedExtraDevicesSerializesConcurrentRuns(t *testing.T) {
var infoRequests atomic.Int32
release := make(chan struct{})
// Only /info blocks and counts. A successful registration spawns its own
// background status-update request (see the comment below), which must
// not be mistaken for a second concurrent seed probe.
server := httptest.NewTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/info" {
http.NotFound(w, r)
return
}
infoRequests.Add(1)
<-release // block until the test lets the handler respond
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<info deviceID="TESTDEVICE"><name>Slow speaker</name><type>SoundTouch 10</type></info>`))
}))
server.Start()
host := strings.TrimPrefix(server.URL, "http://")
app := NewWebApp()
app.ExtraDeviceHosts = func() ([]string, error) { return []string{host}, nil }
done := make(chan struct{}, 2)
go func() { app.SeedExtraDevices(); done <- struct{}{} }()
go func() { app.SeedExtraDevices(); done <- struct{}{} }()
// Give both goroutines a moment to reach the handler if they were going
// to run concurrently, then let the handler(s) respond.
time.Sleep(50 * time.Millisecond)
close(release)
<-done
<-done
if got := infoRequests.Load(); got != 1 {
t.Fatalf("/info request count = %d, want 1 (concurrent seeds were not serialized)", got)
}
// AddDeviceByHost spawns a one-shot status-update goroutine and a 30s-
// ticker poll loop on successful registration (see
// TestDiscoverDevicesRetriesConfiguredHosts). Give them a moment to
// finish before the deferred server.Close() runs, so a still-in-flight
// request against the closing httptest server doesn't produce log noise
// or -race flakiness.
time.Sleep(50 * time.Millisecond)
}
func TestRemoveDeviceIfMatchKeepsReplacement(t *testing.T) {
app := NewWebApp()
original := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{})
replacement := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{})
app.AddDevice("speaker", original)
app.RemoveDevice("speaker")
app.AddDevice("speaker", replacement)
if app.removeDeviceIfMatch("speaker", original) {
t.Fatal("removeDeviceIfMatch removed a replacement connection")
}
got, ok := app.GetDevice("speaker")
if !ok || got != replacement {
t.Fatal("replacement connection was not preserved")
}
app.RemoveDevice("speaker")
}
+96 -33
View File
@@ -62,7 +62,12 @@ type WebApp struct {
// soundtouch-service points it at the service datastore's known devices so
// the UI shows manually-added speakers even when network discovery is
// disabled. Standalone soundtouch-player leaves it nil.
ExtraDeviceHosts func() []string
//
// A non-nil error means the underlying read failed (e.g. a datastore
// glitch), which callers must NOT treat the same as "zero hosts
// persisted" -- doing so would make a transient read failure look like
// every persisted device is already registered.
ExtraDeviceHosts func() ([]string, error)
// TriggerDiscovery, when set, runs an external discovery sweep instead of
// this app's own mDNS/UPnP. The embedded build wires it to the host
@@ -78,6 +83,11 @@ type WebApp struct {
// removal only prunes the in-memory registry).
RemoveDeviceHook func(deviceID string) error
// seedMu serializes seedExtraDevices runs so the bounded startup retry
// loop (SeedExtraDevicesUntilReady) and a devices-changed-hook-triggered
// SeedExtraDevices never probe the same still-offline host concurrently.
seedMu sync.Mutex
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
@@ -104,11 +114,12 @@ func (app *WebApp) proxyServiceURL() string {
return app.ServiceURL
}
// DeviceEntry pairs a device id with its connection. Used by
// DeviceSnapshot so callers can iterate without holding the lock.
// DeviceEntry pairs a device id with its connection and the LastSeen value
// captured by DeviceSnapshot under the registry lock.
type DeviceEntry struct {
ID string
Device *webtypes.DeviceConnection
ID string
Device *webtypes.DeviceConnection
LastSeen time.Time
}
// NewWebApp creates a new WebApp instance for SPA mode
@@ -132,9 +143,9 @@ func (app *WebApp) GetDevice(id string) (*webtypes.DeviceConnection, bool) {
return device, ok
}
// DeviceSnapshot returns a list of (id, *DeviceConnection) pairs taken
// under a single read lock. Callers can iterate the result without
// holding any registry lock. Devices added or removed after the call
// DeviceSnapshot returns device entries taken under a single read lock.
// Callers can iterate the result without holding any registry lock.
// Devices added or removed after the call
// are not reflected. A pointer captured here stays valid even if the
// device is later removed (RemoveDevice only detaches it from the map
// and stops its goroutines), so iterating a stale snapshot is safe.
@@ -144,7 +155,11 @@ func (app *WebApp) DeviceSnapshot() []DeviceEntry {
out := make([]DeviceEntry, 0, len(app.devices))
for id, device := range app.devices {
out = append(out, DeviceEntry{ID: id, Device: device})
out = append(out, DeviceEntry{
ID: id,
Device: device,
LastSeen: device.LastSeen,
})
}
return out
@@ -214,25 +229,36 @@ func (app *WebApp) RemoveDevice(id string) bool {
return ok
}
// removeDeviceIfMatch removes id only when it still points at expected. It is
// used when an asynchronous probe must not delete a newer replacement that was
// registered under the same host.
func (app *WebApp) removeDeviceIfMatch(id string, expected *webtypes.DeviceConnection) bool {
app.devicesMu.Lock()
current, ok := app.devices[id]
if ok && current == expected {
delete(app.devices, id)
} else {
ok = false
}
app.devicesMu.Unlock()
if ok {
expected.Close()
}
return ok
}
// HandleAPIDevices returns all devices as JSON
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Return all devices as JSON
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
response := webtypes.APIResponse{
Success: true,
Data: devices,
Data: app.deviceViewSnapshot(),
}
if err := json.NewEncoder(w).Encode(response); err != nil {
@@ -702,20 +728,9 @@ func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
message := webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
Data: app.deviceViewSnapshot(),
}
// Send to all connected clients
@@ -1084,6 +1099,54 @@ func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
"Left zone")
}
// HandleGetZoneCandidates returns every registered physical device, for the
// "add to zone" picker. Unlike HandleAPIDevices, this deliberately bypasses
// the stereo-pair projection (deviceViewSnapshot): Zone and Group are
// separate, unrelated groupings, and a device that's currently hidden as a
// stereo-pair member (see device_projection.go) must still be an
// independently addressable zone target, exactly as the backend
// HandleZoneAdd/HandleZoneRemove already treat it (both look devices up via
// the raw registry, unaffected by projection).
//
// Deliberately does not exclude the {id} device itself: which candidates to
// exclude (the page's own device, current zone members, ...) is a caller
// concern, not an inherent property of "what devices exist" -- excluding it
// here would make this endpoint silently unusable for any future caller
// that isn't Zone.js's current "add to my own zone" flow.
func (app *WebApp) HandleGetZoneCandidates(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if _, exists := app.GetDevice(deviceID); !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
type zoneCandidate struct {
Info *models.DeviceInfo `json:"info,omitempty"`
}
candidates := make(map[string]zoneCandidate)
for _, entry := range app.DeviceSnapshot() {
if entry.Device == nil || entry.Device.DeviceInfo == nil {
continue
}
candidates[entry.ID] = zoneCandidate{Info: entry.Device.DeviceInfo}
}
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: candidates,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleDeviceRecents returns recently played items for a device.
func (app *WebApp) HandleDeviceRecents(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
+75
View File
@@ -893,3 +893,78 @@ func TestHandleZoneLeave_UsesRemoveZoneSlave(t *testing.T) {
t.Errorf("removeZoneSlave body should name the master, got: %s", masterBody)
}
}
// TestHandleGetZoneCandidates_IncludesHiddenPairMemberAndSelf guards two
// things the stereo-pair projection (device_projection.go) must not affect:
// Zone and Group are separate, unrelated groupings, so (a) a stereo pair's
// hidden non-master member -- absent from the collapsed "devices" list --
// must still be a valid zone-add candidate, matching how HandleZoneAdd
// already treats it (raw registry lookup, unaffected by projection); and
// (b) the endpoint itself does not exclude the requesting {id} device,
// since deciding what to exclude (this page's own device, current zone
// members, ...) is the caller's concern -- Zone.js already does this via
// the existing zoneIps set, which includes the master's own IP even for a
// standalone zone (see models.ZoneInfo.IsStandalone).
func TestHandleGetZoneCandidates_IncludesHiddenPairMemberAndSelf(t *testing.T) {
app := NewWebApp()
group := testStereoGroup()
master := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "left-id", Name: "Living Room", IPAddress: "192.0.2.10"})
master.SetStatus(&webtypes.DeviceStatus{IsConnected: true, Group: group})
app.AddDevice("192.0.2.10", master)
hiddenMember := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "right-id", Name: "Living Room", IPAddress: "192.0.2.11"})
hiddenMember.SetStatus(&webtypes.DeviceStatus{IsConnected: true, Group: group})
app.AddDevice("192.0.2.11", hiddenMember)
standalone := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{DeviceID: "kitchen-id", Name: "Kitchen", IPAddress: "192.0.2.12"})
standalone.SetStatus(&webtypes.DeviceStatus{IsConnected: true})
app.AddDevice("192.0.2.12", standalone)
// Sanity check: the hidden member really is absent from the projected
// device list this test is guarding against leaking into.
projected := app.deviceViewSnapshot()
if _, visible := projected["192.0.2.11"]; visible {
t.Fatal("test setup: expected 192.0.2.11 to be hidden by the stereo-pair projection")
}
req := httptest.NewRequest("GET", "/api/control/devices/192.0.2.10/zone/candidates", nil)
req = withChiParams(req, map[string]string{"id": "192.0.2.10"})
w := httptest.NewRecorder()
app.HandleGetZoneCandidates(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var response webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
data, ok := response.Data.(map[string]interface{})
if !ok {
t.Fatalf("expected data to be a map, got %T", response.Data)
}
for _, ip := range []string{"192.0.2.10", "192.0.2.11", "192.0.2.12"} {
if _, ok := data[ip]; !ok {
t.Errorf("expected %s in zone candidates, got: %+v", ip, data)
}
}
}
func TestHandleGetZoneCandidates_UnknownDeviceNotFound(t *testing.T) {
app := NewWebApp()
req := httptest.NewRequest("GET", "/api/control/devices/192.0.2.99/zone/candidates", nil)
req = withChiParams(req, map[string]string{"id": "192.0.2.99"})
w := httptest.NewRecorder()
app.HandleGetZoneCandidates(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404 for an unknown device, got %d: %s", w.Code, w.Body.String())
}
}
+1
View File
@@ -86,6 +86,7 @@ func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDis
r.Route("/zone", func(r chi.Router) {
r.Get("/", app.HandleGetZone)
r.Get("/candidates", app.HandleGetZoneCandidates)
r.Post("/add/{slaveId}", app.HandleZoneAdd)
r.Post("/remove/{slaveId}", app.HandleZoneRemove)
r.Post("/dissolve", app.HandleZoneDissolve)
@@ -399,6 +399,8 @@ img { display: block; max-width: 100%; }
}
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; display: flex; gap: .4rem; flex-wrap: wrap; }
.device-ip { color: var(--text); font-family: monospace; font-weight: 500; }
.stereo-pair-state { color: var(--accent); font-weight: 600; }
.stereo-pair-state.degraded { color: var(--offline); }
.device-indicator {
width: 8px; height: 8px; border-radius: 50%;
+25 -3
View File
@@ -5,6 +5,31 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AfterTouch</title>
<meta name="description" content="AfterTouch — A replacement for Bose SoundTouch cloud services. Control your speakers after the cloud shutdown." />
<link rel="icon" type="image/svg+xml" href="/app/static/img/favicon.svg" />
<link rel="alternate icon" href="/app/static/img/favicon.ico" />
<link rel="stylesheet" href="/app/static/css/app.css" />
<!--
Safari on iPadOS 15 supports native ES modules but not import maps
(added in Safari 16.4), which left the page blank there (#649).
es-module-shims polyfills import map support, but it's an ~80KB
uncompressed download; loading it unconditionally would charge every
browser that cost on every page view, including the vast majority
that already support import maps natively. Feature-detect instead,
so only a browser that actually lacks support ever fetches it.
Inserted via the DOM rather than document.write (deprecated, and
subject to browser interventions that can silently drop it), with
async explicitly set to false so it still executes before the
deferred `type="module"` script below runs, without blocking the
parser for the vast majority that never reach this branch.
-->
<script>
if (!(window.HTMLScriptElement && HTMLScriptElement.supports && HTMLScriptElement.supports('importmap'))) {
var esModuleShimsScript = document.createElement('script');
esModuleShimsScript.src = '/app/static/lib/es-module-shims.js';
esModuleShimsScript.async = false;
document.head.appendChild(esModuleShimsScript);
}
</script>
<script type="importmap">
{
"imports": {
@@ -14,9 +39,6 @@
}
}
</script>
<link rel="icon" type="image/svg+xml" href="/app/static/img/favicon.svg" />
<link rel="alternate icon" href="/app/static/img/favicon.ico" />
<link rel="stylesheet" href="/app/static/css/app.css" />
</head>
<body>
<div id="app"></div>
@@ -20,6 +20,7 @@ export const api = {
power: (id) => req(`/api/control/devices/${id}/power`, { method: 'POST' }),
recents: (id) => req(`/api/control/devices/${id}/recents`),
zone: (id) => req(`/api/control/devices/${id}/zone`),
zoneCandidates: (id) => req(`/api/control/devices/${id}/zone/candidates`),
zoneAdd: (masterId, slaveId) => req(`/api/control/devices/${masterId}/zone/add/${slaveId}`, { method: 'POST' }),
zoneRemove: (masterId, slaveId) => req(`/api/control/devices/${masterId}/zone/remove/${slaveId}`, { method: 'POST' }),
zoneDissolve: (id) => req(`/api/control/devices/${id}/zone/dissolve`, { method: 'POST' }),
+12 -2
View File
@@ -102,7 +102,6 @@ function App() {
if (msg.type === 'devices') {
setDevices(msg.data || {});
} else if (msg.type === 'discovery_status') {
console.log('[DEBUG_LOG] discovery_status:', msg.data);
if (msg.data?.isDiscovering !== undefined) {
setIsDiscovering(msg.data.isDiscovering);
} else if (msg.data?.status === 'starting') {
@@ -116,7 +115,11 @@ function App() {
}
} else if (msg.type === 'status_update' && msg.deviceId) {
setDevices(prev => {
if (!prev[msg.deviceId]) return prev;
// Object.prototype.hasOwnProperty, not a plain prev[msg.deviceId]
// truthy check: a deviceId of "__proto__" or "constructor" would
// otherwise resolve through the prototype chain to a truthy value
// and pass the check despite not being a real, known device.
if (!Object.prototype.hasOwnProperty.call(prev, msg.deviceId)) return prev;
return {
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
@@ -135,6 +138,13 @@ function App() {
};
}, []);
useEffect(() => {
if (selectedId && !devices[selectedId]) {
setSelectedId(null);
if (page === 'device') setPage('devices');
}
}, [devices, selectedId, page]);
function showToast(msg) {
setToast(null);
setTimeout(() => setToast(msg), 10);
@@ -23,6 +23,7 @@ function sortEntries(entries, mode) {
function DeviceCard({ id, device, onSelect, onRemove }) {
const { info, status } = device;
const stereoPair = device.stereoPair;
const np = status?.nowPlaying;
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
const isStandby = !np || np.Source === 'STANDBY';
@@ -33,14 +34,19 @@ function DeviceCard({ id, device, onSelect, onRemove }) {
<span class="device-name">${info?.name || id}</span>
<span class="device-header-right">
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
<button class="device-remove" title="Remove this device"
${!stereoPair ? html`<button class="device-remove" title="Remove this device"
aria-label="Remove this device"
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}></button>
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}></button>` : null}
</span>
</div>
<div class="device-type">
${info?.type || ''}
${info?.ip_address ? html`<span class="device-ip">(${info.ip_address})</span>` : null}
${stereoPair ? html`
<span class="stereo-pair-state ${stereoPair.degraded ? 'degraded' : ''}">
Stereo pair ${stereoPair.availableMemberCount}/${stereoPair.memberCount}
</span>
` : null}
</div>
${!isStandby ? html`
<div class="now-playing-mini">
@@ -23,10 +23,27 @@ export function Library({ devices }) {
// invalidate the current selection.
useEffect(() => {
const entries = Object.entries(devices);
if (!deviceId && entries.length > 0) {
setDeviceId(entries[0][0]);
if (deviceId && devices[deviceId]) return;
if (entries.length === 0) {
if (deviceId) setDeviceId(null);
return;
}
}, [devices]);
if (deviceId) {
// The selected device vanished from the list -- if that's because
// it just became a hidden stereo-pair member (see
// device_projection.go), follow it to its pair's master instead
// of silently jumping to an unrelated device.
const master = entries.find(([, d]) => d.stereoPair?.members?.some(m => m.ipAddress === deviceId));
if (master) {
setDeviceId(master[0]);
return;
}
}
setDeviceId(entries[0][0]);
}, [devices, deviceId]);
// Reload registered servers whenever deviceId changes.
useEffect(() => {
@@ -7,13 +7,17 @@ const html = htm.bind(h);
export function Zone({ deviceId, devices }) {
const [zone, setZone] = useState(null);
const [candidates, setCandidates] = useState({});
const [loading, setLoading] = useState(true);
const [showPicker, setShowPicker] = useState(false);
function refresh() {
api.zone(deviceId).then(resp => {
if (resp.success) setZone(resp.data);
}).finally(() => setLoading(false));
Promise.all([api.zone(deviceId), api.zoneCandidates(deviceId)])
.then(([zoneResp, candidatesResp]) => {
if (zoneResp.success) setZone(zoneResp.data);
if (candidatesResp.success) setCandidates(candidatesResp.data || {});
})
.finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, [deviceId]);
@@ -48,11 +52,15 @@ export function Zone({ deviceId, devices }) {
if (!zone) return null;
// Devices not already in the zone are available to add
// Devices not already in the zone are available to add. Sourced from a
// dedicated candidates endpoint rather than the `devices` prop: Zone and
// Group are separate, unrelated groupings, so a stereo pair's hidden
// member -- absent from the collapsed device list -- is still a valid,
// independent zone-add target.
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
const available = Object.entries(candidates).filter(([ip]) => !zoneIps.has(ip));
const deviceName = (ip) => devices[ip]?.info?.name || ip;
const deviceName = (ip) => devices[ip]?.info?.name || candidates[ip]?.info?.name || ip;
return html`
<div class="zone-section">
@@ -0,0 +1,15 @@
# Vendored Frontend Dependencies
The JavaScript files in the parent directory are copied unmodified from the
exact npm packages pinned in the repository's `package-lock.json`.
This directory travels with the embedded web UI and contains each package's
upstream license. Its copy of `package-lock.json` records the exact versions,
npm tarball URLs, and integrity hashes used to generate the assets:
- `preact.module.js` and `preact-hooks.module.js`: `preact`
- `htm.module.js`: `htm`
- `es-module-shims.js`: `es-module-shims`
Run `make update-static-deps` to regenerate the assets, licenses, and package
metadata together.
@@ -0,0 +1,10 @@
MIT License
-----------
Copyright (C) 2018-2021 Guy Bedford
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,49 @@
{
"name": "@gesellix/bose-soundtouch",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gesellix/bose-soundtouch",
"license": "MIT",
"dependencies": {
"es-module-shims": "2.8.4",
"htm": "3.1.1",
"preact": "10.29.8"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/es-module-shims": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/es-module-shims/-/es-module-shims-2.8.4.tgz",
"integrity": "sha512-ea5srn5L89PWVad6Qle6r2kg+HvvLiL/GHqgNx06eFrkERHkrTFWaqo1w8Sd+XI3Xr0iAG5x3ZQ7mQ/hnQzNpA==",
"license": "MIT"
},
"node_modules/htm": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/htm/-/htm-3.1.1.tgz",
"integrity": "sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==",
"license": "Apache-2.0"
},
"node_modules/preact": {
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
},
"peerDependencies": {
"preact-render-to-string": ">=5"
},
"peerDependenciesMeta": {
"preact-render-to-string": {
"optional": true
}
}
}
}
}
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present Jason Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
package soundtouchweb
import (
"bytes"
"io/fs"
"testing"
)
// TestIndexPolyfillsImportMapsForOlderBrowsers guards the Safari-on-iPadOS-15
// fix (#649): that browser supports native ES modules but not import maps
// (added in Safari 16.4), so the page loaded blank there. es-module-shims
// polyfills import map resolution for such browsers, but is an ~80KB
// uncompressed download, so it must be feature-detected and only injected
// for a browser that actually lacks HTMLScriptElement.supports('importmap'),
// before the import map is parsed -- not loaded unconditionally for every
// browser.
func TestIndexPolyfillsImportMapsForOlderBrowsers(t *testing.T) {
index, err := fs.ReadFile(StaticFS, "static/index.html")
if err != nil {
t.Fatalf("read index: %v", err)
}
if !bytes.Contains(index, []byte(`HTMLScriptElement.supports('importmap')`)) {
t.Fatal("index.html does not feature-detect import map support before loading es-module-shims")
}
if bytes.Contains(index, []byte(`document.write(`)) {
t.Fatal("index.html must not call document.write() (deprecated, subject to browser interventions); use DOM insertion instead")
}
if !bytes.Contains(index, []byte(`.async = false`)) {
t.Fatal("the dynamically-inserted es-module-shims script must set async = false to preserve execution order")
}
shimIdx := bytes.Index(index, []byte(`esModuleShimsScript.src = '/app/static/lib/es-module-shims.js';`))
if shimIdx == -1 {
t.Fatal("index.html does not conditionally inject es-module-shims.js")
}
importMapIdx := bytes.Index(index, []byte(`<script type="importmap">`))
if importMapIdx == -1 {
t.Fatal("index.html does not declare an import map")
}
if shimIdx > importMapIdx {
t.Fatal("the es-module-shims feature-detect/injection must come before the import map so it can polyfill browsers without native support")
}
if _, err := fs.Stat(StaticFS, "static/lib/es-module-shims.js"); err != nil {
t.Errorf("es-module-shims is not vendored/embedded: %v", err)
}
}
// TestVendoredDependenciesIncludeLicenses ensures that the license and package
// metadata copied with the embedded frontend assets are also present in release
// binaries. scripts/update-static-deps.sh owns these generated files.
func TestVendoredDependenciesIncludeLicenses(t *testing.T) {
for _, dependency := range []string{"preact", "htm", "es-module-shims"} {
path := "static/lib/LICENSES/" + dependency + "-LICENSE"
if _, err := fs.Stat(StaticFS, path); err != nil {
t.Errorf("vendored dependency license %q: %v", path, err)
}
}
if _, err := fs.Stat(StaticFS, "static/lib/LICENSES/package-lock.json"); err != nil {
t.Errorf("vendored dependency provenance: %v", err)
}
}
// TestIndexImportMapCoversAllVendoredModules guards against the import map
// and the vendored files it points at drifting apart.
func TestIndexImportMapCoversAllVendoredModules(t *testing.T) {
index, err := fs.ReadFile(StaticFS, "static/index.html")
if err != nil {
t.Fatalf("read index: %v", err)
}
cases := []struct {
specifier string
mappedURL string
embeddedPath string
}{
{"preact", "/app/static/lib/preact.module.js", "static/lib/preact.module.js"},
{"preact/hooks", "/app/static/lib/preact-hooks.module.js", "static/lib/preact-hooks.module.js"},
{"htm", "/app/static/lib/htm.module.js", "static/lib/htm.module.js"},
}
for _, c := range cases {
if !bytes.Contains(index, []byte(`"`+c.specifier+`": "`+c.mappedURL+`"`)) {
t.Errorf("import map does not map %q to %q", c.specifier, c.mappedURL)
}
if _, err := fs.Stat(StaticFS, c.embeddedPath); err != nil {
t.Errorf("vendored dependency %q: %v", c.specifier, err)
}
}
}
// TestPreactHooksUsesUnmodifiedPeerImport guards against re-introducing a
// sed-patched vendor file: with the import map restored, the vendored
// preact/hooks build's peer import of "preact" must stay a bare specifier,
// resolved like every other component through the import map (and, for
// browsers that need it, through the es-module-shims polyfill) rather than
// a hardcoded relative path baked in at vendoring time.
func TestPreactHooksUsesUnmodifiedPeerImport(t *testing.T) {
hooks, err := fs.ReadFile(StaticFS, "static/lib/preact-hooks.module.js")
if err != nil {
t.Fatalf("read preact-hooks.module.js: %v", err)
}
if !bytes.Contains(hooks, []byte(`from"preact"`)) {
t.Error(`preact-hooks.module.js should import the unmodified bare "preact" specifier`)
}
}
+69 -32
View File
@@ -46,23 +46,10 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
}
// Send initial device list
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
initialMessage := webtypes.WebSocketMessage{
if err := conn.WriteJSON(webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
}
if err := conn.WriteJSON(initialMessage); err != nil {
Data: app.deviceViewSnapshot(),
}); err != nil {
log.Printf("Failed to send initial data: %v", err)
return
}
@@ -100,25 +87,39 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
// Send periodic status updates
for _, entry := range app.DeviceSnapshot() {
status := entry.Device.Status()
if status.IsConnected {
statusMessage := webtypes.WebSocketMessage{
Type: "status_update",
DeviceID: entry.ID,
Data: status,
}
if err := conn.WriteJSON(statusMessage); err != nil {
log.Printf("Failed to send status update: %v", err)
return
}
for _, message := range app.periodicPlayerMessages() {
if err := conn.WriteJSON(message); err != nil {
log.Printf("Failed to send device update: %v", err)
return
}
}
}
}
// periodicPlayerMessages refreshes the projected inventory while retaining
// the established per-device status_update stream for API clients.
func (app *WebApp) periodicPlayerMessages() []webtypes.WebSocketMessage {
snapshot := captureDeviceProjectionEntries(app.DeviceSnapshot())
messages := []webtypes.WebSocketMessage{{
Type: "devices",
Data: projectCapturedDeviceEntries(snapshot),
}}
for _, entry := range snapshot {
if entry.Status == nil || !entry.Status.IsConnected {
continue
}
messages = append(messages, webtypes.WebSocketMessage{
Type: "status_update",
DeviceID: entry.ID,
Data: entry.Status,
})
}
return messages
}
// HandleAPIDiscover triggers device discovery
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -218,6 +219,10 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
})
})
wsClient.OnGroupUpdated(func(event *models.GroupUpdatedEvent) {
applyGroupUpdatedEvent(conn, event)
})
if err := wsClient.Connect(); err != nil {
log.Printf("Failed to connect WebSocket for device %s: %v (retrying in %s)", sanitizeLog(deviceID), err, backoff)
@@ -298,6 +303,16 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
return
}
// /getGroup must be gated to ST10 models -- see Client.GetGroup's doc
// comment (verified against real hardware: a ST20 never replies at all,
// hanging until the client's timeout instead of returning quickly).
stereoCapable := stereoPairCapable(conn.DeviceInfo)
var groupGeneration uint64
if stereoCapable {
groupGeneration = conn.BeginGroupRefresh()
}
// Phase 1: slow network fetches. Local vars only, no shared state
// is touched yet. Errors are recorded so the merge below can tell
// "field N stayed unchanged" apart from "field N got refreshed".
@@ -307,6 +322,15 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
sources, sourcesErr := conn.Client.GetSources()
bass, bassErr := conn.Client.GetBass()
var (
group *models.Group
groupErr error
)
if stereoCapable {
group, groupErr = conn.Client.GetGroup()
}
// Phase 2: fast merge. Only fields we successfully fetched
// overwrite; everything else keeps the value other goroutines may
// have just written.
@@ -338,11 +362,24 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
statusUpdated = true
}
// Mark as connected if we successfully got at least one
// status from this round. Mirrors prior behaviour.
// Mark as connected if we successfully got at least one status
// from this round. Mirrors prior behaviour: deliberately does NOT
// fold groupErr in here. GetGroup is gated to stereo-capable
// models and trivially succeeds even when a device is otherwise
// struggling (an empty <group/> is a near-guaranteed reply), so
// counting it would let a device report connected while every
// substantive status fetch above actually failed this round.
s.IsConnected = statusUpdated
s.LastActivity = time.Now()
})
if stereoCapable && groupErr == nil {
conn.ApplyPolledGroup(groupGeneration, group)
}
}
func applyGroupUpdatedEvent(conn *webtypes.DeviceConnection, event *models.GroupUpdatedEvent) {
conn.ApplyGroupEvent(&event.Group, time.Now())
}
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
+250
View File
@@ -0,0 +1,250 @@
package soundtouchweb
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
func TestUpdateDeviceStatusRefreshesGroup(t *testing.T) {
server := newStatusTestServer(t, http.StatusOK, `<group id="pair-1">
<name>Living Room</name>
<masterDeviceId>master-1</masterDeviceId>
<roles>
<groupRole><deviceId>master-1</deviceId><role>LEFT</role></groupRole>
<groupRole><deviceId>member-1</deviceId><role>RIGHT</role></groupRole>
</roles>
<status>GROUP_OK</status>
</group>`)
defer server.Close()
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
NewWebApp().UpdateDeviceStatus("device-1", conn)
status := conn.Status()
if status.Group == nil {
t.Fatal("Group was not populated by UpdateDeviceStatus")
}
if status.Group.ID != "pair-1" || status.Group.MasterDeviceID != "master-1" {
t.Errorf("Group = %+v, want refreshed stereo pair", status.Group)
}
if len(status.Group.Roles.Roles) != 2 {
t.Errorf("group roles = %d, want 2", len(status.Group.Roles.Roles))
}
if !status.IsConnected {
t.Error("successful status refresh should mark the device connected")
}
}
func TestUpdateDeviceStatusPreservesGroupOnError(t *testing.T) {
server := newStatusTestServer(t, http.StatusInternalServerError, "group unavailable")
defer server.Close()
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
existing := &models.Group{ID: "pair-old", Name: "Existing Pair"}
conn.SetStatus(&webtypes.DeviceStatus{Group: existing})
NewWebApp().UpdateDeviceStatus("device-1", conn)
status := conn.Status()
if status.Group != existing {
t.Errorf("Group = %+v, want previous group preserved on refresh error", status.Group)
}
if !status.IsConnected {
t.Error("other successful status fetches should keep the device connected")
}
}
func TestUpdateDeviceStatusSkipsGroupForNonStereoModel(t *testing.T) {
var groupRequested atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getGroup" {
groupRequested.Store(true)
http.Error(w, "unsupported endpoint", http.StatusInternalServerError)
return
}
responses := map[string]string{
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets/>`,
"/sources": `<sources/>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
body, ok := responses[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(body))
}))
defer server.Close()
for _, model := range []string{"SoundTouch 20", "SoundTouch 30"} {
t.Run(model, func(t *testing.T) {
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: model})
NewWebApp().UpdateDeviceStatus("device-1", conn)
if !conn.Status().IsConnected {
t.Fatal("successful ordinary status requests should mark a non-stereo model connected")
}
})
}
if groupRequested.Load() {
t.Fatal("UpdateDeviceStatus requested /getGroup for a non-stereo model")
}
}
// TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds covers a stereo-
// capable device where every substantive status fetch fails but /getGroup
// alone succeeds (a near-guaranteed reply -- even an empty <group/> is a
// success, see Client.GetGroup's doc comment). IsConnected must not be set
// from GetGroup's success alone, or a device with genuinely stale
// NowPlaying/Volume/Presets/Sources/Bass data would be reported connected.
func TestUpdateDeviceStatusNotConnectedWhenOnlyGroupSucceeds(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getGroup" {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<group/>`))
return
}
http.Error(w, "device struggling", http.StatusInternalServerError)
}))
defer server.Close()
conn := webtypes.NewDeviceConnection(client.NewClientFromHost(server.URL), &models.DeviceInfo{Type: "SoundTouch 10"})
NewWebApp().UpdateDeviceStatus("device-1", conn)
if conn.Status().IsConnected {
t.Fatal("IsConnected must stay false when every substantive status fetch failed, even though GetGroup alone succeeded")
}
}
func TestApplyGroupUpdatedEventReplacesGroup(t *testing.T) {
conn := webtypes.NewDeviceConnection(nil, nil)
previousActivity := time.Unix(1, 0)
conn.SetStatus(&webtypes.DeviceStatus{
Group: &models.Group{ID: "pair-old"},
Volume: &models.Volume{ActualVolume: 25},
IsConnected: true,
LastActivity: previousActivity,
})
event := &models.GroupUpdatedEvent{
Group: models.Group{ID: "pair-new", Name: "Renamed Pair"},
}
applyGroupUpdatedEvent(conn, event)
status := conn.Status()
if status.Group != &event.Group || status.Group.ID != "pair-new" {
t.Errorf("Group = %+v, want event group", status.Group)
}
if status.Volume == nil || status.Volume.ActualVolume != 25 || !status.IsConnected {
t.Errorf("unrelated status fields were not preserved: %+v", status)
}
if !status.LastActivity.After(previousActivity) {
t.Errorf("LastActivity = %s, want after %s", status.LastActivity, previousActivity)
}
teardown := &models.GroupUpdatedEvent{Group: models.Group{}}
applyGroupUpdatedEvent(conn, teardown)
if conn.Status().Group != nil {
t.Errorf("teardown event did not clear the group: %+v", conn.Status().Group)
}
}
func TestPeriodicPlayerMessagesPreserveStatusUpdateStream(t *testing.T) {
app := NewWebApp()
group := testStereoGroup()
for _, entry := range []DeviceEntry{
projectionDevice("192.0.2.10", "left-id", "Living Room", true, group),
projectionDevice("192.0.2.11", "right-id", "Living Room", true, group),
projectionDevice("192.0.2.12", "standalone-id", "Kitchen", false, nil),
} {
app.AddDevice(entry.ID, entry.Device)
}
messages := app.periodicPlayerMessages()
if len(messages) != 3 {
t.Fatalf("periodic messages = %d, want one devices frame and two connected status updates: %+v", len(messages), messages)
}
if messages[0].Type != "devices" {
t.Fatalf("first periodic message type = %q, want devices", messages[0].Type)
}
devices, ok := messages[0].Data.(map[string]deviceView)
if !ok || len(devices) != 2 || devices["192.0.2.10"].StereoPair == nil {
t.Fatalf("periodic devices frame is not the logical projection: %#v", messages[0].Data)
}
statusUpdates := make(map[string]bool)
for _, message := range messages[1:] {
if message.Type != "status_update" {
t.Fatalf("periodic message type = %q, want status_update", message.Type)
}
statusUpdates[message.DeviceID] = true
}
if !statusUpdates["192.0.2.10"] || !statusUpdates["192.0.2.11"] || statusUpdates["192.0.2.12"] {
t.Fatalf("unexpected status_update device IDs: %+v", statusUpdates)
}
}
func newStatusTestServer(t *testing.T, groupStatus int, groupBody string) *httptest.Server {
t.Helper()
responses := map[string]string{
"/now_playing": `<nowPlaying source="STANDBY"><playStatus>STOP_STATE</playStatus></nowPlaying>`,
"/volume": `<volume><targetvolume>10</targetvolume><actualvolume>10</actualvolume><muteenabled>false</muteenabled></volume>`,
"/presets": `<presets/>`,
"/sources": `<sources/>`,
"/bass": `<bass><targetbass>0</targetbass><actualbass>0</actualbass></bass>`,
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method for %s = %s, want GET", r.URL.Path, r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/getGroup" {
w.WriteHeader(groupStatus)
_, _ = w.Write([]byte(groupBody))
return
}
body, ok := responses[r.URL.Path]
if !ok {
t.Errorf("unexpected status endpoint %q", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(body))
}))
}
@@ -3,9 +3,11 @@
package webtypes
import (
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -75,6 +77,7 @@ func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
conn.SetStatus(&DeviceStatus{
Volume: &models.Volume{ActualVolume: 10},
Bass: &models.Bass{ActualBass: 3},
Group: &models.Group{ID: "pair-1", Name: "Living Room"},
IsConnected: true,
})
@@ -92,11 +95,120 @@ func TestUpdateStatus_PreservesUnchangedFields(t *testing.T) {
t.Errorf("Bass not preserved: %+v", got.Bass)
}
if got.Group == nil || got.Group.ID != "pair-1" {
t.Errorf("Group not preserved: %+v", got.Group)
}
if !got.IsConnected {
t.Error("IsConnected not preserved")
}
}
func TestDeviceStatusGroupJSON(t *testing.T) {
status := DeviceStatus{
Group: &models.Group{
ID: "pair-1",
Name: "Living Room",
MasterDeviceID: "master-1",
},
}
payload, err := json.Marshal(status)
if err != nil {
t.Fatalf("Marshal DeviceStatus: %v", err)
}
var decoded struct {
Group *models.Group `json:"group"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("Unmarshal DeviceStatus: %v", err)
}
if decoded.Group == nil || decoded.Group.ID != "pair-1" || decoded.Group.MasterDeviceID != "master-1" {
t.Fatalf("group did not round-trip in status JSON: %+v", decoded.Group)
}
emptyPayload, err := json.Marshal(DeviceStatus{})
if err != nil {
t.Fatalf("Marshal empty DeviceStatus: %v", err)
}
var emptyDecoded map[string]json.RawMessage
if err := json.Unmarshal(emptyPayload, &emptyDecoded); err != nil {
t.Fatalf("Unmarshal empty DeviceStatus: %v", err)
}
if _, ok := emptyDecoded["group"]; ok {
t.Errorf("nil group should be omitted, JSON = %s", emptyPayload)
}
}
func TestGroupEventSupersedesInFlightPoll(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
generation := conn.BeginGroupRefresh()
eventGroup := &models.Group{ID: "new-pair", MasterDeviceID: "master"}
if !conn.ApplyGroupEvent(eventGroup, time.Now()) {
t.Fatal("new group event should change group state")
}
if conn.ApplyPolledGroup(generation, &models.Group{ID: "stale-pair"}) {
t.Fatal("stale poll must not replace a newer group event")
}
if got := conn.Status().Group; got == nil || got.ID != "new-pair" {
t.Fatalf("Group = %+v, want newer event state", got)
}
}
func TestEmptyGroupClearsCurrentClaim(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
conn.SetStatus(&DeviceStatus{Group: &models.Group{ID: "pair-1"}})
if !conn.ApplyGroupEvent(&models.Group{}, time.Now()) {
t.Fatal("empty teardown event should change group state")
}
if got := conn.Status().Group; got != nil {
t.Fatalf("Group = %+v, want nil after teardown", got)
}
}
// TestApplyGroupEventIgnoresRoleOrder guards replaceGroup's change-detection
// against a spurious "changed" report when the same pair's roles simply
// arrive in a different order -- a polled /getGroup response and a pushed
// groupUpdated event both populate Roles.Roles straight from XML unmarshal
// in wire order, so nothing guarantees they list LEFT/RIGHT the same way
// every time for the identical pair.
func TestApplyGroupEventIgnoresRoleOrder(t *testing.T) {
conn := NewDeviceConnection(nil, &models.DeviceInfo{Name: "test"})
leftFirst := &models.Group{
ID: "pair-1",
MasterDeviceID: "master",
Roles: models.GroupRoles{Roles: []models.GroupRole{
{DeviceID: "master", Role: "LEFT"},
{DeviceID: "member", Role: "RIGHT"},
}},
}
conn.SetStatus(&DeviceStatus{Group: leftFirst})
rightFirst := &models.Group{
ID: "pair-1",
MasterDeviceID: "master",
Roles: models.GroupRoles{Roles: []models.GroupRole{
{DeviceID: "member", Role: "RIGHT"},
{DeviceID: "master", Role: "LEFT"},
}},
}
if conn.ApplyGroupEvent(rightFirst, time.Now()) {
t.Fatal("reordered roles for the same pair must not report a change")
}
}
func TestStatusSnapshotIsolation(t *testing.T) {
// A snapshot returned by Status() must NOT change when a later
// UpdateStatus replaces a pointer field. This proves the atomic
+66 -5
View File
@@ -47,6 +47,12 @@ type DeviceConnection struct {
status atomic.Pointer[DeviceStatus]
// groupMu orders polled /getGroup responses against real-time
// groupUpdated events. Starting a newer refresh or receiving an event
// invalidates any older in-flight poll.
groupMu sync.Mutex
groupGeneration uint64
// done is closed by Close when the device is removed from the
// registry, signalling its background goroutines (the status poller
// and the WebSocket reconnect loop) to exit. closeOnce keeps Close
@@ -62,6 +68,7 @@ type DeviceStatus struct {
Presets *models.Presets `json:"presets,omitempty"`
Sources *models.Sources `json:"sources,omitempty"`
Bass *models.Bass `json:"bass,omitempty"`
Group *models.Group `json:"group,omitempty"`
IsConnected bool `json:"isConnected"`
LastActivity time.Time `json:"lastActivity"`
}
@@ -85,10 +92,9 @@ func NewDeviceConnection(c *client.Client, info *models.DeviceInfo) *DeviceConne
}
// Status returns a snapshot of the current device status. The returned
// pointer is read-only from the caller's perspective; mutating the
// pointed-to struct has no effect on the stored status. Use
// UpdateStatus or SetStatus to apply changes. Never returns nil for
// connections built via NewDeviceConnection.
// pointer is read-only from the caller's perspective and must not be
// mutated. Use UpdateStatus or SetStatus to apply changes. Never returns
// nil for connections built via NewDeviceConnection.
func (c *DeviceConnection) Status() *DeviceStatus {
return c.status.Load()
}
@@ -128,7 +134,7 @@ func (c *DeviceConnection) SetStatus(s *DeviceStatus) {
// writers cannot silently lose each other's changes.
//
// The copy mut receives is a shallow value copy of the previous status.
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass)
// Nested pointer fields (NowPlaying, Volume, Presets, Sources, Bass, Group)
// share their backing struct with the previous version: callers MUST
// REPLACE these pointers (s.Volume = &models.Volume{...}) rather than
// mutate through them (s.Volume.ActualVolume++ would race with any
@@ -147,6 +153,61 @@ func (c *DeviceConnection) UpdateStatus(mut func(*DeviceStatus)) {
}
}
// BeginGroupRefresh starts a new generation for an asynchronous /getGroup
// request. Only the latest started request may later update Group.
func (c *DeviceConnection) BeginGroupRefresh() uint64 {
c.groupMu.Lock()
defer c.groupMu.Unlock()
c.groupGeneration++
return c.groupGeneration
}
// ApplyPolledGroup stores a /getGroup result only when no newer poll or
// groupUpdated event superseded it. Empty groups clear the current claim.
func (c *DeviceConnection) ApplyPolledGroup(generation uint64, group *models.Group) bool {
c.groupMu.Lock()
defer c.groupMu.Unlock()
if generation != c.groupGeneration {
return false
}
return c.replaceGroup(normalizeGroup(group), time.Time{})
}
// ApplyGroupEvent stores the newest groupUpdated event and invalidates all
// in-flight /getGroup requests. Empty teardown events clear the current claim.
func (c *DeviceConnection) ApplyGroupEvent(group *models.Group, activity time.Time) bool {
c.groupMu.Lock()
defer c.groupMu.Unlock()
c.groupGeneration++
return c.replaceGroup(normalizeGroup(group), activity)
}
func (c *DeviceConnection) replaceGroup(group *models.Group, activity time.Time) bool {
changed := !models.SameGroup(c.Status().Group, group)
c.UpdateStatus(func(s *DeviceStatus) {
s.Group = group
if !activity.IsZero() {
s.LastActivity = activity
}
})
return changed
}
func normalizeGroup(group *models.Group) *models.Group {
if group == nil || group.IsEmpty() {
return nil
}
return group
}
// APIResponse is a standard JSON response wrapper
type APIResponse struct {
Success bool `json:"success"`
+32 -3
View File
@@ -21,7 +21,11 @@ If your device doesn't expose the port, you can still use the on-device installe
The storage space on the SoundTouch devices is very limited — stock rootfs typically has only a few MB free (e.g. ~4 MB on the ST20, see issue #268), well below the AfterTouch binary's ~12 MB. To work around this, the installer puts everything on `/mnt/nv/aftertouch` by default (the persistent partition, typically ~30 MB free) and points `/opt/aftertouch` at it via a symlink so the init script and runtime paths stay unchanged. Override the install target with `INSTALL_DIR=/some/path` if you've got room elsewhere.
The space limitation also means we are currently unsure on how to update the system, because two binaries are already too large. We are currently working on this - both by checking how we can make the binaries smaller, but also on how we can extend the storage space (e.g. by running AfterTouch from a USB drive).
Updating is genuinely tight on this partition, since the old binary, the new one, and a rollback backup can't all comfortably fit at once, and binaries only keep growing (the Go toolchain's own defaults alone add hundreds of KB per major version, independent of anything in this project). The installer handles this in a few ways:
- The rollback backup is gzip-compressed (`.backup.gz`) rather than a plain copy, cutting its footprint by roughly a third.
- Before downloading anything, it checks whether there's actually enough free space for the update, using the new binary's real size (a HEAD request), not a guess.
- If there's enough room for the update itself but not enough extra for a backup, it asks for confirmation before proceeding without one — reading from `/dev/tty` since the installer is normally run as `curl | sh`. The default (empty input, or no `/dev/tty` available at all) is always to abort rather than silently skip the backup; set `AFTERTOUCH_FORCE_NO_BACKUP=yes` to skip that prompt for unattended/scripted installs.
- If there isn't even enough room for the update itself, it aborts before downloading anything, rather than leaving a partially-overwritten, non-executable binary in place.
### Logs
@@ -177,14 +181,39 @@ redirect to discover the newest tag. If that lookup fails (offline, or a `curl`
build without `-w` support), it falls back to a pinned version baked into the
script.
> **Tip — rollback:** if the new binary misbehaves, the installer left a `.backup` file alongside it:
> **Tip — rollback:** if the new binary misbehaves, the installer left a backup file
> alongside it, gzip-compressed as `.backup.gz` (plain `.backup`, uncompressed, if
> `gzip` wasn't available on your device):
> ```bash
> ls /mnt/nv/aftertouch/aftertouch-service*.backup
> ls /mnt/nv/aftertouch/aftertouch-service*.backup*
> # .backup.gz (compressed):
> gunzip -c /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup.gz \
> > /mnt/nv/aftertouch/aftertouch-service
> chmod +x /mnt/nv/aftertouch/aftertouch-service
> # or, for an uncompressed .backup:
> cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
> /mnt/nv/aftertouch/aftertouch-service
> /etc/init.d/aftertouch restart
> ```
## Environment Variables
All of these go on `sh`, not `curl` — in a pipe, each command is a separate
process, so `VAR=X curl ... | sh` silently does NOT set it for `sh` (the one
that actually reads it). Use `curl -sSL .../install.sh | VAR=X sh` instead.
| Variable | Default | Purpose |
|------------------------------|-----------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `VERSION` | resolved automatically to the latest GitHub release | Install a specific version instead of latest. See [Updating AfterTouch](#updating-aftertouch) for the three equivalent ways to set this. |
| `INSTALL_DIR` | `/mnt/nv/aftertouch` | Where AfterTouch is installed. `/opt/aftertouch` is symlinked here so the init script's hardcoded path keeps working. |
| `AFTERTOUCH_FORCE_NO_BACKUP` | unset | Skip the interactive "not enough space for a backup, continue anyway?" prompt and proceed without a rollback backup. For unattended/scripted installs only — interactively, the installer always asks (or aborts if no terminal is available) rather than silently skipping the backup. |
| `AFTERTOUCH_LAN_PORT` | `auto` | Written into `aftertouch.conf` (not consumed by the installer itself beyond that). Controls whether/which LAN entry-port gets redirected to AfterTouch. See [Model Support Matrix](../../docs/content/docs/reference/MODEL-SUPPORT-MATRIX.md). |
| `UPDATE_TMP_DIR` | `/media/aftertouch` | Scratch directory for the downloaded binary before it replaces the installed one. Should stay on a different filesystem than `INSTALL_DIR` (tmpfs by default) so the download itself doesn't compete with `INSTALL_DIR` for space. |
| `GH_REPO` | `gesellix/Bose-SoundTouch` | Install from a fork instead, e.g. for testing an unmerged branch's release. |
| `BINARY_URL` | derived from `GH_REPO`/`VERSION` | Override the service binary's download URL entirely, bypassing `GH_REPO`/`VERSION` for this one file. |
| `INIT_SCRIPT_URL` | derived from `GH_REPO`/`VERSION` | Override the init script's download URL entirely, bypassing `GH_REPO`/`VERSION` for this one file. |
| `FALLBACK_VERSION` | `0.123.0` | Used only if the latest-release lookup fails (offline, rate-limited, or a `curl` build without `-w` support). |
## Uninstallation
Before uninstall, you might want to revert the migration, especially the changes to the server URLs (even though having configured an unresponsive local server probably is about as bad as having configured unresponsive Bose servers). To uninstall AfterTouch, run the following command on the speaker.
+103 -2
View File
@@ -97,6 +97,7 @@ fi
# prior run was interrupted.
echo "Disk usage before pre-install GC:"; df -h "$INSTALL_DIR"
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
"$INSTALL_DIR/aftertouch-service".*.backup.gz \
"$INSTALL_DIR/aftertouch-service".*.old \
"$INSTALL_DIR/aftertouch-service.new"; do
[ -f "$f" ] || continue
@@ -105,6 +106,86 @@ for f in "$INSTALL_DIR/aftertouch-service".*.backup \
done
echo "Disk usage after pre-install GC:"; df -h "$INSTALL_DIR"
# --- Preflight disk-space check ------------------------------------------
# /mnt/nv is small (tens of MB) and binaries keep growing (Go 1.27 alone
# added ~640KB to this binary via its own new stdlib defaults, unrelated to
# this project's code). A prior attempt on real hardware ran out of space
# mid-replace and left a truncated, non-executable binary in place: UBIFS is
# a log-structured flash filesystem, so space freed by overwriting the old
# binary isn't necessarily reusable by the time the new one needs to land.
# Check upfront, with a safety margin, instead of discovering this mid-write.
#
# The new binary's size comes from a HEAD request rather than a hardcoded
# threshold, so this doesn't go stale as binaries grow across releases.
NEW_BINARY_BYTES=$(curl -sSLI --fail "$BINARY_URL" 2>/dev/null \
| tr -d '\r' \
| awk 'tolower($1) == "content-length:" {v=$2} END {print v}') || true
AVAILABLE_KB=$(df -Pk "$INSTALL_DIR" | awk 'NR==2 {print $4}')
CURRENT_BINARY_KB=0
if [ -f "$INSTALL_DIR/aftertouch-service" ]; then
CURRENT_BINARY_KB=$(du -k "$INSTALL_DIR/aftertouch-service" | awk '{print $1}')
fi
# Flat margin, not a percentage: covers UBIFS's own reserved/GC headroom on
# this log-structured flash filesystem plus general slack.
SAFETY_MARGIN_KB=5120 # 5 MB
SKIP_BACKUP=no
if [ -n "$NEW_BINARY_BYTES" ]; then
NEW_BINARY_KB=$((NEW_BINARY_BYTES / 1024))
# Backups compress to roughly 70% of the original size in practice
# (observed: a ~14.8MB binary gzipped to ~10.1MB); used as a conservative
# estimate since the real ratio isn't known until compression actually runs.
BACKUP_ESTIMATE_KB=$((CURRENT_BINARY_KB * 7 / 10))
NEEDED_WITH_BACKUP_KB=$((NEW_BINARY_KB + BACKUP_ESTIMATE_KB + SAFETY_MARGIN_KB))
NEEDED_NO_BACKUP_KB=$((NEW_BINARY_KB + SAFETY_MARGIN_KB))
if [ "$AVAILABLE_KB" -ge "$NEEDED_WITH_BACKUP_KB" ]; then
: # plenty of room; proceed normally, with a backup
elif [ "$AVAILABLE_KB" -ge "$NEEDED_NO_BACKUP_KB" ]; then
echo "WARNING: not enough free space on $INSTALL_DIR to keep a rollback" >&2
echo "backup this time (${AVAILABLE_KB}KB available; ~${NEEDED_WITH_BACKUP_KB}KB" >&2
echo "wanted with a backup, ~${NEEDED_NO_BACKUP_KB}KB without one)." >&2
echo "Continuing will replace the current binary with NO way to" >&2
echo "automatically undo it if something goes wrong." >&2
if [ -n "${AFTERTOUCH_FORCE_NO_BACKUP:-}" ]; then
echo "Proceeding without a backup (AFTERTOUCH_FORCE_NO_BACKUP is set)." >&2
SKIP_BACKUP=yes
elif [ -r /dev/tty ] && [ -w /dev/tty ]; then
printf 'Continue without a backup? [y/N] ' > /dev/tty
REPLY=""
read -r REPLY < /dev/tty || true
case "$REPLY" in
[Yy]*) SKIP_BACKUP=yes ;;
*)
echo "Aborting: refusing to proceed without a backup. Free up space" >&2
echo "on $INSTALL_DIR and try again, or set AFTERTOUCH_FORCE_NO_BACKUP=yes" >&2
echo "to proceed without one non-interactively." >&2
exit 1
;;
esac
else
echo "No interactive terminal available to confirm; aborting." >&2
echo "Set AFTERTOUCH_FORCE_NO_BACKUP=yes to proceed without a backup" >&2
echo "non-interactively." >&2
exit 1
fi
else
echo "ERROR: not enough free space on $INSTALL_DIR to install AfterTouch" >&2
echo "$VERSION safely (${AVAILABLE_KB}KB available, ~${NEEDED_NO_BACKUP_KB}KB" >&2
echo "needed). Free up space and try again." >&2
exit 1
fi
else
echo "WARNING: could not determine the new binary's size ahead of time" >&2
echo "(HEAD request to $BINARY_URL failed); skipping the preflight" >&2
echo "disk-space check." >&2
fi
curl \
-sSL \
-o "$UPDATE_TMP_DIR/binary" \
@@ -114,15 +195,34 @@ curl \
# Back up the current binary before overwriting so a one-step rollback
# is always available. The version string comes from the binary itself;
# if it is absent (very old build or corrupted) we fall back to a timestamp.
# Skipped entirely when the preflight check above decided (with the
# operator's explicit confirmation, or AFTERTOUCH_FORCE_NO_BACKUP) that
# there isn't room for one.
BACKUP_FILE=""
if [ -f "$INSTALL_DIR/aftertouch-service" ]; then
if [ -f "$INSTALL_DIR/aftertouch-service" ] && [ "$SKIP_BACKUP" != "yes" ]; then
current_version=$("$INSTALL_DIR/aftertouch-service" --version 2>/dev/null \
| awk '{print $NF}') || true
if [ -z "$current_version" ] || [ "$current_version" = "dev" ]; then
current_version=$(date +%Y%m%d-%H%M%S)
fi
# Binaries are tens of MB and only growing (see #614 investigation into
# Go 1.27's default binary-size increase), while /mnt/nv is small (tens of
# MB total). Stream straight into the compressed file rather than cp-then-
# gzip: at this point in the script the old binary is still live AND the
# newly-downloaded one is already sitting in $UPDATE_TMP_DIR, so an
# intermediate uncompressed backup copy would briefly need all three full
# copies on disk at once -- exactly the kind of moment that has already
# caused "no space left on device" failures here. Best effort: if gzip is
# missing, or the stream fails partway (e.g. disk fills mid-compress),
# fall back to a plain uncompressed copy exactly as before.
BACKUP_FILE="$INSTALL_DIR/aftertouch-service.${current_version}.backup"
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
if command -v gzip >/dev/null 2>&1 \
&& gzip -c < "$INSTALL_DIR/aftertouch-service" > "$BACKUP_FILE.gz"; then
BACKUP_FILE="$BACKUP_FILE.gz"
else
rm -f "$BACKUP_FILE.gz"
cp -p "$INSTALL_DIR/aftertouch-service" "$BACKUP_FILE"
fi
echo "Backed up current binary ($current_version) → $BACKUP_FILE"
fi
@@ -136,6 +236,7 @@ chmod +x "$INSTALL_DIR/aftertouch-service"
if [ -n "$BACKUP_FILE" ]; then
echo "Disk usage before post-install GC:"; df -h "$INSTALL_DIR"
for f in "$INSTALL_DIR/aftertouch-service".*.backup \
"$INSTALL_DIR/aftertouch-service".*.backup.gz \
"$INSTALL_DIR/aftertouch-service".*.old \
"$INSTALL_DIR/aftertouch-service.new"; do
[ -f "$f" ] || continue
+17 -12
View File
@@ -5,18 +5,8 @@ set -e
LIB_DIR="pkg/service/soundtouchweb/static/lib"
mkdir -p "$LIB_DIR"
echo "Updating static frontend dependencies from node_modules..."
# Ensure dependencies are installed
if [ ! -d "node_modules" ] || [ ! -d "node_modules/preact" ] || [ ! -d "node_modules/htm" ]; then
if [ "$CI" = "true" ]; then
echo "node_modules not found or incomplete. Running npm ci in CI environment..."
npm ci
else
echo "node_modules not found or incomplete. Running npm install..."
npm install
fi
fi
echo "Installing static frontend dependencies from package-lock.json..."
npm ci --ignore-scripts
# Copy files from node_modules
echo "Copying Preact..."
@@ -28,4 +18,19 @@ cp node_modules/preact/hooks/dist/hooks.module.js "$LIB_DIR/preact-hooks.module.
echo "Copying HTM..."
cp node_modules/htm/dist/htm.module.js "$LIB_DIR/htm.module.js"
# Polyfills import map support for browsers that have ES modules but not
# import maps (e.g. Safari on iPadOS 15, see #649). Safe to always vendor:
# it detects native import map support and becomes a no-op there.
echo "Copying ES Module Shims..."
cp node_modules/es-module-shims/dist/es-module-shims.js "$LIB_DIR/es-module-shims.js"
# Keep license and package provenance inside the embedded static tree so they
# ship with release binaries, not only with source checkouts.
LICENSE_DIR="$LIB_DIR/LICENSES"
mkdir -p "$LICENSE_DIR"
for dependency in preact htm es-module-shims; do
cp "node_modules/$dependency/LICENSE" "$LICENSE_DIR/$dependency-LICENSE"
done
cp package-lock.json "$LICENSE_DIR/package-lock.json"
echo "All dependencies updated successfully from node_modules."