Compare commits

...
37 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.8 b29dd1bd77 refs(models): remove the deprecated ZoneRequest member helpers (refs #511)
Delete RemoveMember, ClearMembers and HasMember (deprecated in the
previous commit) plus their tests. They had no production callers after
the zone remove paths moved to /removeZoneSlave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00
Tobias GesellchenandClaude Opus 4.8 94496d5d4b refs(models): deprecate unused ZoneRequest member helpers (refs #511)
RemoveMember, ClearMembers and HasMember have no production callers: the
zone remove paths now use /removeZoneSlave instead of a /setZone rebuild,
and standalone is done by dissolving the zone. Mark them Deprecated ahead
of removal in the next commit (keeps history legible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00
Tobias GesellchenandClaude Opus 4.8 720d2abc5c fix(zone): remove a member via /removeZoneSlave instead of a /setZone rebuild (refs #511)
Removing one member from a multi-member zone did nothing. The remove
paths rebuilt the zone with /setZone and the remaining members, but
/setZone is additive: it never drops a member that is simply absent from
the list. It only "removed" when the resulting set was empty (equivalent
to dissolve), which is why removing the last member worked but removing
one of several did not.

Switch all three remove paths to the dedicated /removeZoneSlave endpoint
(already implemented as client.RemoveZoneSlave):

- HandleZoneRemove  (web UI "remove member")
- HandleZoneLeave   (web UI slave "leave zone")
- RemoveFromZone    (client lib, used by CLI `zone remove`)

DissolveZone (setZone master-only) and HandleZoneAdd (additive setZone)
are correct and unchanged. Adds handler regression tests for remove/leave
and rewrites TestClient_RemoveFromZone to assert /removeZoneSlave (the old
test removed one of two members but only checked that setZone was called,
never that the member was dropped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:25:41 +02:00
Tobias GesellchenandClaude Opus 4.8 462b4179f1 fix(docs,service): persist the Docker data dir at /app/data + warn when empty (refs #517)
The walkthrough mounted the volume at /data, but the image's DATA_DIR is
/app/data, so the documented docker run never actually persisted the
datastore, settings or CA; a recreated container silently lost all state.
Correct the mount path, document what lives under /app/data and the cost
of losing it, and add a Windows/macOS Docker Desktop note (host
networking is Linux-only; publish ports; DNS interception needs :53/:443).
The service also logs a clear notice on startup when the data dir looks
empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 18f3eabd67 docs(api,health): note /speaker play_info needs DNS interception (refs #517)
play_info notifications make the speaker validate the app_key via
GET /v1/auth against a hardcoded Bose host; without DNS interception that
call can't resolve and /speaker times out with ALLEGROWEBSERVER_TIMEOUT
(1046). Document the requirement on POST /speaker (plus the no-DNS
LOCAL_INTERNET_RADIO alternative), and have the "Test DNS path" health
check mention that TTS/play_info depends on the same DNS path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 788a6ced93 fix(setup): re-install the CA when it was regenerated, not just label-matched (refs #517)
checkCACertTrusted matched only the static "# AfterTouch" label in the
device's trust bundle. After the service CA was regenerated (e.g. a
recreated container with a fresh/empty data dir), the stale label was
still present, so the migration wrongly reported the speaker as already
trusting the new CA and skipped re-installing it, leaving the speaker
unable to validate TLS to the service.

When the service CA is available, compare the actual cert payload and
re-install on mismatch; fall back to the label only when the CA can't be
read (CLI callers without Crypto). Adds regression tests for the
stale-label and no-Crypto cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 eab5241e6d docs: fix doc links flagged by markdown-link-check (refs #521)
- radio-browser.md: correct the relative path to the troubleshooting
  section (../guides/TROUBLESHOOTING.md#..., not ../../guides/.../).
- TROUBLESHOOTING.md: drop a same-page fragment link to the emoji
  "Getting More Help" heading (github-slugger anchor was unstable);
  reference the section in prose instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias GesellchenandClaude Opus 4.8 96cdc050d1 docs(CLAUDE): document how to decrypt diagnostic reports
Use the repo's own scripts/decrypt-diagnostic.go (not the generic age
CLI), unpack per-file next to the .age, and note the archive layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias GesellchenandClaude Opus 4.8 9d8e8f4858 docs(troubleshooting): radio sources not activating after in-place migration (refs #521)
After an in-place migration the firmware sometimes does not activate the
radio source types (LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER) even
though the entries are present in the device's own Sources.xml; a reboot
and a sourcesUpdated notification do not help. The root cause is not yet
understood, so this documents the user-confirmed workaround (factory
reset + re-migrate) rather than changing migration behaviour:

- New troubleshooting section with a stable anchor, linked from the
  sources_xml_diff health check and the Radio Browser reference.
- Capture the speaker's on-device /mnt/nv/BoseApp-Persistence/1/Sources.xml
  in the diagnostic export (when SSH is available), so a future report
  taken before a factory reset carries the evidence to pin down the cause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:34:09 +02:00
Tobias Gesellchen c318dbb99f docs(web): chore 2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 ea7f6f36ef feat(install): default installers to the latest release via releases/latest
The on-device and Raspberry Pi installers hardcoded the release version, which
had to be bumped on every release. Default VERSION to empty and resolve the
newest tag by following GitHub's documented stable redirect
(https://github.com/<repo>/releases/latest -> .../releases/tag/vX.Y.Z), reading
the effective URL. This avoids the GitHub API rate limit and needs no jq.

An explicit version (positional arg / VERSION= / --version) still pins a
release. If the lookup fails (offline, rate-limited, or a curl without -w
support), each script falls back to a pinned FALLBACK_VERSION so installs still
work. The Pi self_update path runs after resolution, so it fetches the resolved
tag's installer.

Docs updated to state the default installs the latest release; the pinned-version
examples remain as illustrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 01ebbb102d docs(install): bump example/default version v0.107.0 -> v0.111.3
The installer docs and the on-device + Raspberry Pi installer scripts all
defaulted to and showed v0.107.0. Update every install example and the
VERSION defaults to the current release v0.111.3 across the on-device and
Pi guides and scripts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 2e6e88dd8c feat(scripts): add Raspberry Pi uninstallers + fix stale install-web.sh docs
soundtouch-web was renamed to soundtouch-player and the web UI merged into
soundtouch-service, so the player is now optional. The Raspberry Pi / host
installers had no matching uninstaller (removal was only documented as manual
commands), and users who installed the old soundtouch-web have a leftover
service with no scripted way to remove it.

Add three uninstallers under scripts/raspberry-pi/, each mirroring its
installer's conventions and tolerant of already-missing pieces:

- uninstall.sh        — soundtouch-service; preserves the data directory by
                        default, --purge / PURGE_DATA=true to delete it.
- uninstall-player.sh — soundtouch-player (stateless).
- uninstall-web.sh    — leftover soundtouch-web; points users at install-player.sh.

The shared soundtouch:soundtouch user/group is removed only once no other
soundtouch-{service,player,web} install remains on the host.

Docs: the README and guides still told users to fetch install-web.sh to install
the player. Switch those to install-player.sh, keep but improve the manual
removal commands (note the service datastore is preserved unless explicitly
deleted), document the new uninstallers, and add a "Migrating from soundtouch-web"
section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00
Tobias GesellchenandClaude Opus 4.8 902b9d8402 fix(setup/ui): passive-observer no-inbound is a warning, not a failure (refs #471)
The migration pre-flight "Reachability check (passive observer)" reported a
timed-out no-inbound as a hard failure, so the panel showed "N of M checks
failed" and forced a Proceed Anyway. That outcome is usually just timing: the
speaker's swUpdate daemon dials out on its own slow schedule and a reboot
after Apply validates the fan-out. One reporter wrongly suspected custom
service ports were to blame (#471, Leeto001).

Introduce a proper non-blocking `warn` status (amber, no fail count) and
downgrade the no-inbound case to it, with a message that explains the timing
and states it is not a port or config problem and is safe to proceed. The
pre-flight summaries now surface a warning count alongside the passed/skipped
counts and keep auto-proceeding; genuine probe errors still fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:43:44 +02:00
dependabot[bot] dbe292a045 ci(deps): bump softprops/action-gh-release from 3.0.0 to 3.0.1
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.1.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...718ea10b132b3b2eba29c1007bb80653f286566b)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 09:33:58 +02:00
Tobias GesellchenandClaude Opus 4.8 1c6f4c9eb8 fix(release): build the tagged commit and stamp the real version (#525)
v0.114.0 binaries reported version 0.0.0 in the web UI. Two root causes,
both fixed here.

1. The release build relied solely on Go's VCS stamping of
   info.Main.Version and never injected a version. When v0.114.0 was
   re-released via workflow_dispatch from `main` (one commit past the
   tag) with a shallow checkout, no tag was reachable, so Go stamped a
   v0.0.0-<ts>-<sha> pseudo-version. The asset filenames used the
   validated input version, so the files were named v0.114.0 but
   reported 0.0.0 at runtime.

2. The `release` and `workflow_dispatch` triggers followed two distinct
   patterns. On `release` every job's checkout landed on the tagged
   commit (GITHUB_SHA == tag); on `workflow_dispatch` they all built
   whatever branch the run started from. So a manual dispatch built the
   wrong source entirely (binaries and Docker images alike).

Changes:

- Unify both triggers on the git tag. `validate` resolves the tag once
  (inputs.tag on dispatch, release.tag_name on a release event), verifies
  it exists in git, and exposes it as an output. Every other job checks
  out `ref: needs.validate.outputs.tag`, so the build is always the
  tagged commit regardless of trigger. The dispatch path now re-releases
  an existing tag (push the tag first) instead of creating one from a
  branch; it fails fast if the tag is missing.
- Inject -X main.version/commit/date into the release binaries, mirroring
  the Dockerfile (which has done this since #422). version/commit no
  longer depend on git stamping; commit is read from the checked-out HEAD
  (not github.sha, which on dispatch is the branch HEAD). Both binaries
  and Docker images take the v-prefixed tag (needs.validate.outputs.tag)
  so the displayed version stays "v0.114.0", matching prior releases.
- Guard updateBuildInfo() in all four cmd/*/main.go so an injected
  version (version != "dev") is never clobbered by a VCS pseudo-version.
  `go install …@vX.Y.Z` still resolves the tag via build info as before.
- Collapse the duplicated `if event_name == workflow_dispatch` tag
  derivations and route tag/version through needs.validate.outputs.*.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:33:48 +02:00
dependabot[bot] b04d7f2fe1 ci(deps): bump actions/checkout in the actions-core group
Bumps the actions-core group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions-core
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-25 09:26:52 +02:00
Tobias GesellchenandClaude Opus 4.8 2744708a9d chore(release): drop the transitional soundtouch-web alias
soundtouch-web was a transitional alias of soundtouch-player. Stop
building and publishing it everywhere, and refresh the release notes
while at it:

- release.yml: remove the soundtouch-web binary, its individual and
  combined checksums, and its release assets (EXPECTED_COUNT 35 -> 28);
  drop the ghcr.io/...-web Docker image steps. Also slim the
  workflow_dispatch release notes to an accurate AfterTouch header plus
  GitHub's auto-generated changelog, with the bare tag as the title.
- Dockerfile: drop the soundtouch-web image stage.
- Makefile: remove WEB_NAME and the build-web target (and its use in
  build/install).
- Delete scripts/raspberry-pi/install-web.sh (it fetched a release asset
  that is no longer published) and point the docs at install-player.sh.
- Correct README, CLAUDE.md, and main.go wording that claimed the alias
  was still published.

The runtime notice for a binary still run under the soundtouch-web name
is kept, so anyone who renamed the binary is nudged to soundtouch-player.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:29:23 +02:00
Tobias GesellchenandClaude Opus 4.8 9331e63b2d feat(setup): add enable-ssh --full-config for devices where sshd never starts (#515)
The default `setup enable-ssh` injects the remote_services/sshd payload
only via `envswitch boseurls set` and relies on the speaker re-reading its
boseurls (~60s) without a reboot. On the SoundTouch Portable (Series I,
FW 27.0.6.46330.5043500) and some CineMate 520 units the device accepts and
persists that injection (getpdo confirms) but sshd never comes up, so :22
stays "Connection refused".

@Henri-be got root on the ST Portable by typing a different sequence by hand
over telnet :17000: the injection rides `sys configuration margeServerUrl`
(the runtime layer) as well as `envswitch`, all four URL keys are written,
and the device is rebooted so it re-parses the config at boot.

Add an opt-in `--full-config` flag that replicates that exact sequence
(EnableSSHViaTelnetFullConfig + telnet reboot via the existing
RebootMethodTelnet). The default single-envswitch path is unchanged, so the
field-confirmed flow on the Wireless Link Adapter and CineMate 520 `lisa`
variant does not regress. Docs (TELNET-COMMAND-REFERENCE, DEVICE-LOGGING)
document both paths and which device models/firmware need `--full-config`.

The flag automation is candidate behaviour awaiting reporter confirmation:
the manual sequence is confirmed on the ST Portable, the flag is not yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:15 +02:00
dependabot[bot] 4e25dacb0d deps(deps): bump golang.org/x/image in the golang group
Bumps the golang group with 1 update: [golang.org/x/image](https://github.com/golang/image).


Updates `golang.org/x/image` from 0.42.0 to 0.43.0
- [Commits](https://github.com/golang/image/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.43.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 18:09:39 +02:00
dependabot[bot] f92aad0612 deps(deps): bump github.com/hashicorp/mdns from 1.0.6 to 1.0.7
Bumps [github.com/hashicorp/mdns](https://github.com/hashicorp/mdns) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/hashicorp/mdns/releases)
- [Commits](https://github.com/hashicorp/mdns/compare/v1.0.6...v1.0.7)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/mdns
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:37:43 +02:00
dependabot[bot] 7a5a587f2a deps(deps): bump the golang group with 9 updates
Bumps the golang group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.52.0` | `0.53.0` |
| [golang.org/x/net](https://github.com/golang/net) | `0.55.0` | `0.56.0` |
| [golang.org/x/term](https://github.com/golang/term) | `0.43.0` | `0.44.0` |
| [golang.org/x/image](https://github.com/golang/image) | `0.41.0` | `0.42.0` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.36.0` | `0.37.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.20.0` | `0.21.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.45.0` | `0.46.0` |
| [golang.org/x/text](https://github.com/golang/text) | `0.37.0` | `0.38.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.45.0` | `0.46.0` |


Updates `golang.org/x/crypto` from 0.52.0 to 0.53.0
- [Commits](https://github.com/golang/crypto/compare/v0.52.0...v0.53.0)

Updates `golang.org/x/net` from 0.55.0 to 0.56.0
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

Updates `golang.org/x/term` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

Updates `golang.org/x/image` from 0.41.0 to 0.42.0
- [Commits](https://github.com/golang/image/compare/v0.41.0...v0.42.0)

Updates `golang.org/x/mod` from 0.36.0 to 0.37.0
- [Commits](https://github.com/golang/mod/compare/v0.36.0...v0.37.0)

Updates `golang.org/x/sync` from 0.20.0 to 0.21.0
- [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0)

Updates `golang.org/x/sys` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/text` from 0.37.0 to 0.38.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.37.0...v0.38.0)

Updates `golang.org/x/tools` from 0.45.0 to 0.46.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.42.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/mod
  dependency-version: 0.37.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sync
  dependency-version: 0.21.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.46.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/text
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/tools
  dependency-version: 0.46.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-15 18:19:59 +02:00
Tobias GesellchenandClaude Opus 4.8 23949189f7 fix(player): stop navbar title/icons overlapping on small screens; show device names in grouping
#500: the absolutely-centered page title and the right-aligned icon bar
shared the same space in the fixed-height navbar and overlapped on phones
(portrait). On <=600px the navbar now wraps into two rows: row 1 keeps the
logo with the title beside it (the title fills the remaining width and
ellipsizes), and the icon bar drops onto its own centered, full-width row
below. CSS-only.

#498: the zone/grouping UI showed raw IP addresses instead of device names.
Root cause was a field-name casing bug: Zone.js read info.Name (uppercase),
but the device info field is info.name (lowercase) everywhere else in the UI
(app.js, DeviceList, Library, TTS, ...). So the lookup always missed and fell
back to the IP. Fixed the casing in the deviceName() helper, and made the
"Add to zone" picker show the device name with the IP as a smaller secondary
line (reusing the .picker-device-info/name/ip pattern the other pickers
already use). Member/master rows resolve names via deviceName().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:02 +02:00
Tobias GesellchenandClaude Opus 4.8 d6257e6108 fix(player): set STORED_MUSIC type when replaying a recent (fix INVALID_SOURCE)
Replaying a STORED_MUSIC item from Recents sent the speaker a ContentItem with
an empty type (recents carry no contentItemType for STORED_MUSIC), and the
speaker rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. The
library play paths work because they pass type "track"/"dir".

HandleDevicePlay now derives the type from the speaker-native location, which
ends with the item kind (e.g. "1$4$2 TRACK" -> "track"), when the caller didn't
supply one. (The recents account itself is already correct via the #503 fix.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:02 +02:00
Tobias GesellchenandClaude Opus 4.8 b8de0f90f0 feat(player): allow playing a Library folder (queue it) so next/prev work (refs #501)
DLNA STORED_MUSIC playback stopped after one track and next/previous did nothing:
the Library UI only offered a play button on individual tracks and always
selected with type "track", so the speaker had no queue to advance through
(next/prev send the NEXT_TRACK/PREV_TRACK key, which needs a queue).

- playEntry now passes the entry's own type, so selecting a folder uses the
  container type ("dir") instead of "track" — letting the speaker queue the
  folder for next/previous + auto-advance.
- show the play button on folders too (title "Play folder"), in addition to
  navigating into them.

Server-side needs no change: HandlePlayLibrary already forwards the type to the
speaker's /select. Whether a given firmware queues a container select is to be
confirmed on hardware (testable with cmd/example-dlna-server).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:48:02 +02:00
Tobias GesellchenandClaude Opus 4.8 0cdf8deb3b fixup: refine CodeQL XSS autofix (fakespeaker round-trip + lint)
Follow-up to the Copilot Autofix commit for the reflected-XSS finding.

- fakespeaker buildAddGroupResponse: the autofix modelled only
  name/master/slave, dropping the posted masterDeviceId, roles, id, and
  senderIPAddress that the client (pkg/models.Group) actually sends and
  TestFakeSpeakerAddGroupEchoesWithGroupOK expects to survive the echo.
  Parse into the canonical models.Group and re-marshal it, so values stay
  XML-escaped (CodeQL-clean) and the fake can't drift from the real
  request schema. Updates the now-stale doc comment.
- marge ProviderSettingsToXML / fakespeaker: satisfy golangci-lint
  (wsl_v5 cuddled type decls, gofmt trailing blank lines) the autofix
  left behind.

make lint clean; marge, handlers, and fakespeaker suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias Gesellchenandlnx01 557e92682f Potential fix for pull request finding 'CodeQL / Reflected cross-site scripting'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 841a9047e2 style(example-dlna-server): satisfy golangci-lint (nilerr, revive)
Two lint fixes on the DLNA test server, no behaviour change:

- nilerr: the "skip unreadable file, keep walking" branch in the
  --media-dir WalkDir callback returns nil after a non-nil read error
  by design; annotate it with //nolint:nilerr, matching the existing
  skip-entry branch above it.
- revive (redefines-builtin-id): rename between()'s `close` parameter
  (and `open` for symmetry) to closeTag/openTag so it no longer shadows
  the builtin `close`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 5103f459bf test(example-dlna-server): one container per album dir, real artist from path
--media-dir flattened all tracks into a single container named after --name, so
browsing showed one "<--name>" dir (and a doubled breadcrumb) instead of the
real album folder, and every track's artist was the hardcoded "Test Artist".

- Group tracks by their containing directory; each becomes its own browsable +
  playable container titled after that directory (e.g. "Sunday at Devil Dirt").
- Derive the artist from the directory above the album
  (<root>/<artist>/<album>/track), falling back to "Unknown Artist"; album stays
  the track's own folder name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 dfa6706703 feat(example-dlna-server): album art, real media via --media-dir, fix discovery, BrowseMetadata + access log
Tooling to reproduce "album cover broken in the player" (disc #499) and to debug
STORED_MUSIC playback against a controllable DLNA source.

- dlnatest.Item gains ArtPayload/ArtMime; the Browse DIDL emits
  <upnp:albumArtURI> and the art bytes are served at /AlbumArt/<id>.<ext>. The
  built-in tracks carry a tiny PNG cover so the repro works with zero setup.
- audio + art are served via http.ServeContent (adds the byte-range support real
  speakers use when streaming).
- example-dlna-server gains --media-dir: serve real .mp3/.wav/.flac/.m4a/.ogg
  files, searched recursively so an artist/album tree works. Art per track: a
  sibling <name>.jpg/.png, else cover.jpg/cover.png/folder.jpg in the album
  folder. Files are read into memory (point it at an album, not a whole library).
- BrowseMetadata: serveContentDir now honours BrowseFlag and returns single-object
  metadata (with the track's <res>). Speakers issue Browse(BrowseMetadata) to
  resolve a track before playing; returning empty caused INVALID_SOURCE.
- fix SSDP discoverability on multi-interface hosts: join the multicast group on
  the interface that owns the LAN IP (macOS lists lo0 first, so the old "first
  multicast interface" join landed on loopback and never heard the LAN M-SEARCH).
- add an HTTP access log (method/path/status/bytes/peer, plus ObjectID+BrowseFlag
  for Browse) so the speaker's request sequence is visible while debugging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:36:01 +02:00
Tobias GesellchenandClaude Opus 4.8 6ad75657e8 fix(datastore): dedup recents by ID in SaveRecents (stop same-recent pile-up)
A speaker<->marge recents sync could re-store the same recent (same ID) multiple
times — observed live as one STORED_MUSIC track appearing 4x in the speaker's
/recents, the service's stored Recents.xml, and /full. The duplicates crowd the
capped (10) recents list and evict other sources (e.g. a freshly played Spotify
track never appears). SaveConfiguredSources already dedups by ID; SaveRecents did
not, so dupes introduced by any path (AddRecent move-to-front, syncRecents from
the speaker's /full, setup/health) persisted and fed back through the sync loop.

SaveRecents now dedups by ID (first occurrence wins) at the single chokepoint all
callers share, so the list self-heals on the next write. Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 21:02:24 +02:00
Tobias GesellchenandClaude Opus 4.8 393b31ad93 fix(marge): stop recents move-to-front from dropping/duplicating entries
Re-playing an existing recent could make it (and its list neighbour)
vanish from the speaker's recents, even with the list well under the
10-item cap. Root cause is a slice-aliasing bug in updateOrCreateRecent's
move-to-front branch:

  recentObj = &recents[i]
  recents = append([]ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
  return recentObj, recents

The inner append(recents[:i], recents[i+1:]...) shifts elements left in
place in the shared backing array, overwriting slot i. The returned
recentObj still points at &recents[i], so it leaks the neighbouring
recent back to the speaker. Worse, Go does not specify evaluation order
between the *recentObj dereference and the inner append call, so the
front element written into the saved list can also read the overwritten
slot, dropping the matched recent and duplicating its neighbour. The
SaveRecents dedup-by-ID guard then collapses that duplicate into a clean
loss.

Verified against recorded interactions (a "White Water" replay returned
the "Sand Castle" recent; both Spotify albums vanished from a 9-item
list) and a live diagnostic export (6 persisted recents, no duplicates,
both albums gone).

Fix: copy the matched recent out first, rebuild into a fresh backing
array, and return a pointer into the new slice. Adds a regression test
that fails on the old code and passes now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:59:35 +02:00
Tobias GesellchenandClaude Opus 4.8 fc3e6ed795 fix(marge): preserve STORED_MUSIC account in recents (fix replay INVALID_SOURCE)
Replaying a STORED_MUSIC media-server item from Recents failed with
INVALID_SOURCE: the served recent's <source> had an empty <username>, so the
speaker fell back to the provider id ("7") as the account and could not resolve
which media server to use.

Root cause: a media server's account ("<UDN>/0") is persisted in
SourceKey.Account, but Username is NOT persisted (SaveConfiguredSources writes
sourceKey.account, not username). prepareRecentItemParitySource and
formatRecentResponse emitted <username> straight from the now-empty Username
field. The /full path (mapToFullResponseSource) already falls back to
SourceKeyAccount; the recents builders did not.

Fix: add recentSourceUsername(src) that falls back to SourceKeyAccount when
Username is empty (TuneIn / Internet Radio / Local Internet Radio keep an empty
username for parity), used by both recent <source> builders. Regression test
drives the captured Bose_Lisa flow (sourceid-only recent POST) and asserts the
served <source><username> is the real UDN, never empty or the bare provider id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:01:07 +02:00
Tobias GesellchenandClaude Opus 4.8 d862666fb7 fix(marge): keep all DLNA media servers registered (don't evict on second add)
A speaker registers each DLNA media server as a STORED_MUSIC source whose
account is "<UDN>/0", and reconciles its source list against marge (/full +
/sources). AddSource deduped STORED_MUSIC by provider ID alone, so registering
a second media server overwrote the first in the datastore; the first then
disappeared from /full + /sources and the speaker dropped it. Only one media
server could ever stay registered.

- STORED_MUSIC now replaces only when the account (SourceKey.Account) matches,
  so distinct servers coexist and re-adding the same server updates in place.
  Other (singleton) providers keep replace-by-provider.
- Generate source IDs from crypto/rand instead of a per-second timestamp.
  SaveConfiguredSources dedups by ID, so two sources created in the same instant
  would otherwise collide and one would be silently dropped; a timestamp (even
  nanosecond) is fragile on coarse clocks, so use 64 bits of randomness with a
  timestamp fallback only if the RNG fails.
- Add a regression test for two coexisting media servers + same-account update.

Diagnosed from speaker + service logs: setMusicServiceAccount succeeds locally,
the speaker pushes AddSource to marge (streaming.bose.com, DNS-intercepted to
AfterTouch), then re-fetches /full + /sources; that list returned only the
latest STORED_MUSIC source, so the speaker pruned the previously-added one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 18:23:29 +02:00
Tobias GesellchenandClaude Opus 4.8 836e985c58 feat(player,cli): nudge sources refresh after adding a media server
Adding a DLNA media server (setMusicServiceAccount) can leave the new
STORED_MUSIC source not fully registered on the speaker, so playing a track
fails with INVALID_SOURCE until a power-cycle. AfterTouch's health
diagnostic already recommends the no-reboot fix: a sourcesUpdated
notification makes the speaker re-fetch its account /full and re-register
its source list.

Fire that nudge automatically right after a successful registration, in
both the player (HandleAddLibraryServer) and the CLI (account add-nas), via
the existing client.NotifySourcesUpdated. It is best-effort: registration
already succeeded, so a failed nudge never fails the request (the handler
returns {account, refreshed}, the CLI prints a warning that a power-cycle
may still be needed). The handler resolves the Bose device ID from the
cached DeviceConnection.DeviceInfo, falling back to GetDeviceInfo.

Note: per the diagnostic, a power-cycle is still occasionally required, so
the nudge is an improvement, not a guarantee.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:56:04 +02:00
dependabot[bot] f010e96699 ci(deps): bump codecov/codecov-action in the security-actions group
Bumps the security-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 6.0.1 to 7.0.0
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/e79a6962e0d4c0c17b229090214935d2e33f8354...fb8b3582c8e4def4969c97caa2f19720cb33a72f)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: security-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 20:49:36 +02:00
dependabot[bot] aa35290bef docker(deps): bump alpine from 3.23 to 3.24
Bumps alpine from 3.23 to 3.24.

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 20:48:37 +02:00
64 changed files with 2599 additions and 1092 deletions
+8 -8
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
file: ./coverage.out
flags: unittests
@@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -107,7 +107,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -165,7 +165,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -192,7 +192,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check documentation links
run: |
@@ -250,7 +250,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -305,7 +305,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install libpcap (required for Go build)
if: matrix.language == 'go'
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
+79 -195
View File
@@ -23,23 +23,27 @@ jobs:
name: Validate Release
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.version.outputs.tag }}
version: ${{ steps.version.outputs.version }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Both triggers resolve to the same thing: the release tag. On a
# `release` event inputs.tag is empty, so this falls back to the
# published release's tag. Every other job checks out this same
# tag (via needs.validate.outputs.tag) so the build is always the
# tagged commit, never whatever branch the dispatch ran on (#525).
ref: ${{ github.event.inputs.tag || github.event.release.tag_name }}
fetch-depth: 0
- name: Validate tag format
id: version
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG_NAME="${{ github.event.inputs.tag }}"
else
TAG_NAME="${GITHUB_REF#refs/tags/}"
fi
# Single source of truth for the tag, regardless of trigger.
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
echo "Tag name: $TAG_NAME"
@@ -50,6 +54,15 @@ jobs:
exit 1
fi
# Confirm the tag actually exists in git. The dispatch path
# re-releases an existing tag; it never creates one from a branch.
if ! git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
echo "❌ Tag $TAG_NAME does not exist in git. Push the tag first, then re-run."
exit 1
fi
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
# Extract version without 'v' prefix
VERSION=${TAG_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
@@ -102,7 +115,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -125,6 +140,11 @@ jobs:
CGO_ENABLED: 0
run: |
# Common variables
# Single build timestamp shared across every binary in this job.
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# Commit of the checked-out tag (not GITHUB_SHA, which on a manual
# dispatch is the branch HEAD the run started from, not the tag).
COMMIT_SHA="$(git rev-parse HEAD)"
ARCH_SUFFIX="${{ matrix.goos }}-${{ matrix.goarch }}"
if [[ "${{ matrix.goarm }}" != "" ]]; then
ARCH_SUFFIX="${ARCH_SUFFIX}v${{ matrix.goarm }}"
@@ -150,9 +170,13 @@ jobs:
# Ensure clean build environment for this binary
rm -f "$OUTPUT_NAME" "$OUTPUT_NAME.sha256" "$OUTPUT_NAME.sha512"
# Inject the validated version (plus commit/date) so the binary
# reports the right version regardless of git checkout state.
# Relying on Go's VCS stamping alone yields v0.0.0-… when built
# from a shallow checkout or a non-tagged commit (see #525).
if ! go build \
-trimpath \
-ldflags="-s -w" \
-ldflags="-s -w -X main.version=${{ needs.validate.outputs.tag }} -X main.commit=${COMMIT_SHA} -X main.date=${BUILD_DATE}" \
-o "$OUTPUT_NAME" \
"$CMD_PATH"; then
echo "❌ Build failed for $BINARY_NAME"
@@ -173,10 +197,6 @@ jobs:
# Build Player (formerly soundtouch-web)
build_binary "soundtouch-player" "./cmd/soundtouch-player"
# Build Web: transitional alias of the player, built from the same
# source. Dropped in a future release; keep in sync with player.
build_binary "soundtouch-web" "./cmd/soundtouch-player"
# Build Backup
build_binary "soundtouch-backup" "./cmd/soundtouch-backup"
id: build
@@ -186,7 +206,6 @@ jobs:
CLI_NAME="${{ steps.build.outputs.soundtouch-cli }}"
SVC_NAME="${{ steps.build.outputs.soundtouch-service }}"
PLAYER_NAME="${{ steps.build.outputs.soundtouch-player }}"
WEB_NAME="${{ steps.build.outputs.soundtouch-web }}"
BCK_NAME="${{ steps.build.outputs.soundtouch-backup }}"
# Use atomic operations to avoid conflicts
@@ -204,7 +223,6 @@ jobs:
generate_checksums "$CLI_NAME"
generate_checksums "$SVC_NAME"
generate_checksums "$PLAYER_NAME"
generate_checksums "$WEB_NAME"
generate_checksums "$BCK_NAME"
# Cleanup
@@ -219,7 +237,6 @@ jobs:
build/soundtouch-cli-v*
build/soundtouch-service-v*
build/soundtouch-player-v*
build/soundtouch-web-v*
build/soundtouch-backup-v*
retention-days: 1
@@ -247,7 +264,7 @@ jobs:
mkdir -p release-files
# Move all files from subdirectories to the collection directory
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
find . -mindepth 2 -type f \( -name "soundtouch-cli-*" -o -name "soundtouch-service-*" -o -name "soundtouch-player-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
# Remove empty directories
find . -type d -empty -delete
@@ -262,14 +279,14 @@ jobs:
# Generate combined checksums (exclude individual .sha256/.sha512 files)
if ls soundtouch-* 1> /dev/null 2>&1; then
# Only checksum the actual binaries, not the .sha256/.sha512 files
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-* soundtouch-service-* soundtouch-player-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
echo "📋 Generated combined checksums:"
cat checksums.sha256
# Verify all expected files are present (binaries only, not checksum files)
EXPECTED_COUNT=35 # 7 platforms * 5 binaries (player + its web alias)
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
@@ -312,8 +329,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.validate.outputs.tag }}
fetch-depth: 0
- name: Download release assets
@@ -325,172 +343,61 @@ jobs:
- name: Generate release notes
id: release_notes
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG_NAME="${{ github.event.inputs.tag }}"
else
TAG_NAME="${{ github.event.release.tag_name }}"
fi
TAG_NAME="${{ needs.validate.outputs.tag }}"
VERSION="${TAG_NAME#v}"
# Generate comprehensive release notes
# Short, accurate header. GitHub's auto-generated "What's Changed"
# + "Full Changelog" are appended after this (generate_release_notes).
cat > release_notes.md << EOF
# Bose SoundTouch Go Library $TAG_NAME
# AfterTouch $TAG_NAME
A comprehensive Go library for controlling Bose SoundTouch speakers with 100% API coverage, real-time WebSocket events, and production-ready features.
**Bose SoundTouch Toolkit.** Keep your Bose SoundTouch speakers alive after the Bose cloud shutdown. No Bose infrastructure required.
## 🎯 Key Features
## What's included
- **100% API Coverage**: All 19 official endpoints + 6 useful extensions (25 total)
- **Real-time Events**: WebSocket support with auto-reconnect and comprehensive event handling
- **Multiroom Control**: Complete zone management and coordination
- **Production Ready**: Connection pooling, error handling, circuit breakers, monitoring
- **Excellent Documentation**: 4000+ lines including Getting Started, Cookbook, Troubleshooting, and Deployment guides
- **CLI Tool**: Full-featured command-line interface with all endpoints
Pre-built binaries for Linux (amd64, arm64, armv7), macOS (Intel & Apple Silicon), Windows (amd64), and FreeBSD (amd64):
## 🚀 Quick Start
- **soundtouch-service**: local server that replaces the Bose cloud. Point your speaker at it and you keep full control; the built-in web UI on port 8000 handles setup.
- **soundtouch-player**: standalone LAN web UI for device control: play/pause, volume, presets, live status. (Formerly \`soundtouch-web\`.)
- **soundtouch-cli**: command-line control of any device: playback, presets, sources, multiroom zones, discovery, and migration. Good for scripting and home automation.
- **soundtouch-backup**: back up your Bose cloud account and each speaker's local state. \`soundtouch-backup all\` captures everything in one step.
## Documentation
Full guides, setup walkthroughs, and troubleshooting: https://gesellix.github.io/Bose-SoundTouch/
## Use as a Go library
The core client is also importable:
\`\`\`bash
go get github.com/gesellix/bose-soundtouch@$TAG_NAME
\`\`\`
\`\`\`go
package main
## Verifying downloads
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
func main() {
// Create client
c := client.New("192.0.2.100", 8090)
// Get device info
info, err := c.GetInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\\n", info.Name)
}
\`\`\`
## 📚 Documentation
- [Getting Started Guide](docs/GETTING-STARTED.md) - 10-minute tutorial from discovery to WebSocket monitoring
- [API Cookbook](docs/API-COOKBOOK.md) - 1000+ lines of real-world patterns and examples
- [Troubleshooting Guide](docs/TROUBLESHOOTING.md) - Systematic issue resolution
- [Deployment Guide](docs/DEPLOYMENT.md) - Production deployment examples (Docker, K8s, systemd)
## 🔧 CLI & Service Tools
Download the tools for your platform from the assets below:
### CLI Tool
\`\`\`bash
# Quick device discovery
./soundtouch-cli -discover
\`\`\`
### SoundTouch Service
\`\`\`bash
# Start the service
./soundtouch-service
\`\`\`
### SoundTouch Player (formerly soundtouch-web)
\`\`\`bash
# Start the LAN web player
./soundtouch-player
\`\`\`
> Note: \`soundtouch-web\` has been renamed to \`soundtouch-player\`.
> The \`soundtouch-web\` assets are still published as a transitional
> alias and will be removed in a future release. Please switch your
> downloads and scripts to \`soundtouch-player\`.
### SoundTouch Backup
\`\`\`bash
# Back up cloud account and all paired speakers in one go
./soundtouch-backup all
\`\`\`
## 🧪 Tested Hardware
- Bose SoundTouch 10
- Bose SoundTouch 20
- All core functionality validated on real devices
## 📈 What's New in $TAG_NAME
$(git log --pretty=format:"- %s" $(git describe --tags --abbrev=0 HEAD^)..HEAD 2>/dev/null || echo "- Initial release with complete feature set")
## 🏗️ Supported Platforms
This release includes pre-built binaries for:
- Linux (amd64, arm64, armv7)
- macOS (Intel & Apple Silicon)
- Windows (amd64)
- FreeBSD (amd64)
`soundtouch-cli`, `soundtouch-service`, `soundtouch-player` (with `soundtouch-web` as a transitional alias), and `soundtouch-backup` are included.
## 🔐 Checksums
Multiple checksum options are provided for download verification:
### Combined Checksums (Recommended)
- \`checksums.sha256\` - SHA256 checksums for all binaries
- \`checksums.sha512\` - SHA512 checksums for all binaries
Each binary has its own \`.sha256\`/\`.sha512\`, and combined \`checksums.sha256\` / \`checksums.sha512\` cover all of them:
\`\`\`bash
# Download any binary + combined checksums
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
curl -L -O https://github.com/.../checksums.sha256
# Verify your specific download
sha256sum -c checksums.sha256 --ignore-missing
\`\`\`
### Individual Checksums (Per Binary)
Each binary also has its own dedicated checksum files:
- \`soundtouch-cli-v$TAG_NAME-platform.sha256\`
- \`soundtouch-cli-v$TAG_NAME-platform.sha512\`
\`\`\`bash
# Download binary + its individual checksum
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64
curl -L -O https://github.com/.../soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
# Verify with individual checksum
sha256sum -c soundtouch-cli-v$TAG_NAME-linux-amd64.sha256
\`\`\`
## 🤝 Contributing
Contributions welcome! See our documentation for examples and patterns.
## 📄 License
MIT License - see [LICENSE](LICENSE) file.
EOF
echo "release_notes_file=release_notes.md" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ github.event.inputs.tag }}
name: "Bose SoundTouch Go Library ${{ github.event.inputs.tag }}"
tag_name: ${{ needs.validate.outputs.tag }}
name: ${{ needs.validate.outputs.tag }}
body_path: ${{ steps.release_notes.outputs.release_notes_file }}
generate_release_notes: true
draft: false
prerelease: ${{ needs.validate.outputs.is_prerelease == 'true' }}
files: |
release-assets/soundtouch-cli-v*
release-assets/soundtouch-service-v*
release-assets/soundtouch-player-v*
release-assets/soundtouch-web-v*
release-assets/soundtouch-backup-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
@@ -512,14 +419,13 @@ jobs:
path: ./release-assets
- name: Upload additional assets to existing release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
tag_name: ${{ github.event.release.tag_name }}
tag_name: ${{ needs.validate.outputs.tag }}
files: |
release-assets/soundtouch-cli-v*
release-assets/soundtouch-service-v*
release-assets/soundtouch-player-v*
release-assets/soundtouch-web-v*
release-assets/soundtouch-backup-v*
release-assets/checksums.sha256
release-assets/checksums.sha512
@@ -534,11 +440,16 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ needs.validate.outputs.tag }}
- name: Set build date
- name: Set build metadata
id: build_date
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
run: |
echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
# Commit of the checked-out tag, not github.sha (the dispatch HEAD).
echo "commit=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -570,8 +481,8 @@ jobs:
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
VERSION=${{ needs.validate.outputs.tag }}
COMMIT=${{ steps.build_date.outputs.commit }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -596,35 +507,8 @@ jobs:
tags: ${{ steps.meta-player.outputs.tags }}
labels: ${{ steps.meta-player.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
# Transitional alias image (formerly the only web image). Dropped later.
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
type=semver,pattern={{version}},value=v${{ needs.validate.outputs.version }}
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.validate.outputs.version }}
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-web
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
VERSION=${{ needs.validate.outputs.tag }}
COMMIT=${{ steps.build_date.outputs.commit }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -639,12 +523,12 @@ jobs:
- name: Notify success
run: |
echo "🎉 Release ${{ needs.validate.outputs.version }} completed successfully!"
echo "📦 Binaries built for 7 platforms (CLI, Service, Web, and Backup)"
echo "📦 Binaries built for 7 platforms (CLI, Service, Player, and Backup)"
echo "🐳 Docker image published to ghcr.io"
echo "🔐 Checksums generated and verified"
echo "📋 Release notes automatically generated"
echo ""
TAG_NAME="${{ github.event.inputs.tag || github.event.release.tag_name }}"
TAG_NAME="${{ needs.validate.outputs.tag }}"
echo "🔗 Release URL: https://github.com/${{ github.repository }}/releases/tag/${TAG_NAME}"
echo ""
echo "Next steps:"
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -92,7 +92,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
if: github.actor == 'dependabot[bot]' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
+29 -1
View File
@@ -43,7 +43,7 @@ Per-session pickup notes live in two local files at the repo root (they are `.gi
make build # All binaries
make build-cli # Just CLI
make build-service # Just service
make build-web # Just web UI
make build-player # Just web player
make build-all # Cross-platform builds (Linux, macOS, Windows)
make install # Install to $GOPATH/bin
@@ -99,6 +99,34 @@ The rotate target is non-destructive (it moves, never deletes) and
opt-in (no other target invokes it). Old archives stay around for
retrospective diffing whenever something goes sideways.
## Decrypting diagnostic reports
Reporters attach an encrypted diagnostic archive
(`aftertouch-diagnostic-*.age`), usually saved under `_/i_<reporter>/`.
Decrypt it with **this repo's own tool**, not the generic `age` CLI:
```bash
go run scripts/decrypt-diagnostic.go <file.age> | tar xz -C <dir containing the .age>
```
- The tool is `scripts/decrypt-diagnostic.go`; the private key lives at
`keys/private/diagnostic` (provisioned by `scripts/setup-diagnostic-key.sh`,
and never committed). It writes the decrypted `.tar.gz` to stdout.
- Always decrypt/unpack next to the `.age` (not a scratch/tmp dir), into a
**per-file subfolder** so nothing collides: e.g.
`mkdir -p <dir>/extracted-<timestamp> && go run scripts/decrypt-diagnostic.go <dir>/<file>.age | tar xz -C <dir>/extracted-<timestamp>`.
This matters when a reporter folder holds multiple `.age` snapshots or
already has other files: every archive uses the same inner names
(`diagnostic.json`, `datastore/`, `http/`, ...), so extracting two into the
same dir overwrites and mixes them.
- The archive contains `diagnostic.json` (health/device summary), `datastore/`
(raw speaker XML: DeviceInfo/Presets/Recents/Sources), `http/` (service
`full.xml`, `sourceproviders.xml`, captured speaker responses), `logs/`,
`settings.json`, `env.txt`, `system/`, `ssh/`. See
`docs/content/docs/appendix/DIAGNOSTIC-EXPORT.md`.
- Reporter data stays under `_/` and is never committed (see "What never goes
into this repo").
## Project structure
```
+2 -21
View File
@@ -46,7 +46,7 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
fi
# soundtouch-service image
FROM alpine:3.23 AS soundtouch-service
FROM alpine:3.24 AS soundtouch-service
RUN apk add --no-cache ca-certificates tzdata
@@ -95,7 +95,7 @@ EXPOSE 8000
ENTRYPOINT ["/app/soundtouch-service"]
# soundtouch-player image
FROM alpine:3.23 AS soundtouch-player
FROM alpine:3.24 AS soundtouch-player
RUN apk add --no-cache ca-certificates tzdata
@@ -112,22 +112,3 @@ EXPOSE 8080
USER nobody
ENTRYPOINT ["/app/soundtouch-player"]
# soundtouch-web image: transitional alias of soundtouch-player. Built from the
# same binary; the entrypoint name makes the binary print a rename notice on
# start. Will be dropped in a future release.
FROM alpine:3.23 AS soundtouch-web
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /soundtouch-player /app/soundtouch-web
ENV PORT=8080
EXPOSE 8080
USER nobody
ENTRYPOINT ["/app/soundtouch-web"]
+2 -13
View File
@@ -19,9 +19,6 @@ SERVICE_NAME=soundtouch-service
SERVICE_PATH=./cmd/$(SERVICE_NAME)
PLAYER_NAME=soundtouch-player
PLAYER_PATH=./cmd/$(PLAYER_NAME)
# WEB_NAME is the previous name for the player, kept as a transitional alias
# built from the same PLAYER_PATH source. It will be dropped in a future release.
WEB_NAME=soundtouch-web
EXAMPLE_MDNS_NAME=example-mdns
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
EXAMPLE_UPNP_NAME=example-upnp
@@ -55,7 +52,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
all: check build
build: build-cli build-service build-player build-web build-examples build-favicon-gen build-backup
build: build-cli build-service build-player build-examples build-favicon-gen build-backup
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -72,13 +69,6 @@ build-player:
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(PLAYER_NAME) $(PLAYER_PATH)
# Transitional alias: builds the same source as build-player under the old
# soundtouch-web name. Drop this target once the alias is retired.
build-web:
@echo "Building $(WEB_NAME) (transitional alias of $(PLAYER_NAME))..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(PLAYER_PATH)
build-examples:
@echo "Building $(EXAMPLE_MDNS_NAME)..."
@mkdir -p $(BUILD_DIR)
@@ -376,12 +366,11 @@ dev-player-host: build-player
fi
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
install: build-cli build-service build-player build-web build-backup
install: build-cli build-service build-player build-backup
@echo "Installing binaries to $(GOPATH)/bin..."
cp $(BUILD_DIR)/$(BINARY_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(SERVICE_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(PLAYER_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(WEB_NAME) $(GOPATH)/bin/
cp $(BUILD_DIR)/$(BACKUP_NAME) $(GOPATH)/bin/
update-static-deps:
+1 -1
View File
@@ -72,7 +72,7 @@ See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/C
### soundtouch-player
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are still published as a transitional alias and will be removed in a future release; please switch to `soundtouch-player`.
> Formerly `soundtouch-web`. The `soundtouch-web` binary, Docker image, and install script are no longer published; please use `soundtouch-player`. (If you still run the binary under its old name, it prints a rename notice and works as before.)
A standalone, LAN-resident web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Because it reaches speakers directly on your network and can delegate cloud-only features (e.g. TTS) to a remote AfterTouch service via `--service-url`, it stays useful when `soundtouch-service` runs off-LAN (for example in the cloud), where the embedded `/app` player cannot reach your speakers.
+359 -19
View File
@@ -17,14 +17,19 @@
package main
import (
"bytes"
"context"
"flag"
"fmt"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -47,6 +52,11 @@ const (
func main() {
port := flag.Int("port", 8200, "HTTP port to bind")
name := flag.String("name", "AfterTouch Test Library", "UPnP friendlyName advertised over SSDP")
mediaDir := flag.String("media-dir", "", "serve real audio files + artwork from this directory "+
"(searched recursively, so an artist/album tree works) instead of the built-in silent test "+
"tracks. Audio: .mp3/.wav/.flac/.m4a/.ogg. Art per track: a sibling <name>.jpg/.png, else a "+
"cover.jpg/cover.png/folder.jpg in the same album folder. Files are loaded into memory, so "+
"point it at an album or a modest folder, not your whole library")
flag.Parse()
@@ -62,11 +72,34 @@ func main() {
addr := fmt.Sprintf("0.0.0.0:%d", *port)
location := fmt.Sprintf("http://%s:%d/rootDesc.xml", lanIP, *port)
srv := dlnatest.NewServer(dlnatest.WithFriendlyName(*name))
// Join the SSDP multicast group on the interface that owns the LAN IP. On
// macOS net.Interfaces() lists lo0 (UP+MULTICAST) first, so picking the
// "first" multicast interface would join on loopback and never receive the
// LAN M-SEARCH from clients like AfterTouch.
lanIface := interfaceForIP(lanIP)
if lanIface != nil {
logger.Info("SSDP: will join multicast on LAN interface", "iface", lanIface.Name, "ip", lanIP)
}
opts := []dlnatest.Option{dlnatest.WithFriendlyName(*name)}
if *mediaDir != "" {
tree, n, err := loadTreeFromDir(*mediaDir, *name)
if err != nil {
logger.Error("failed to load --media-dir", "dir", *mediaDir, "err", err)
os.Exit(1)
}
opts = append(opts, dlnatest.WithTree(tree))
logger.Info("serving real media from directory", "dir", *mediaDir, "tracks", n)
}
srv := dlnatest.NewServer(opts...)
httpSrv := &http.Server{
Addr: addr,
Handler: srv.HTTPHandler(),
Handler: withAccessLog(logger, srv.HTTPHandler()),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -87,7 +120,7 @@ func main() {
udn := srv.UDN
// Start SSDP listener + responder.
go runSSDPListener(ctx, logger, udn, location)
go runSSDPListener(ctx, logger, udn, location, lanIface)
// Start periodic ssdp:alive announcements.
go runSSDPAlive(ctx, logger, udn, location)
@@ -116,27 +149,25 @@ func main() {
// SSDP listener: answers M-SEARCH requests
// ----------------------------------------------------------------------------
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string) {
func runSSDPListener(ctx context.Context, logger *slog.Logger, udn, location string, ifi *net.Interface) {
group := &net.UDPAddr{IP: net.ParseIP(ssdpMulticastIP), Port: ssdpPort}
// ListenMulticastUDP joins the multicast group on a system-chosen interface.
// We iterate over all UP multicast-capable interfaces and listen on each.
ifaces, err := multicastInterfaces()
if err != nil {
logger.Warn("SSDP: cannot list interfaces, using system default", "err", err)
// Join the group on the LAN interface. If we could not resolve it, fall back
// to the first non-loopback multicast interface (never loopback, which would
// only ever receive same-host loopback traffic).
if ifi == nil {
if cands, err := multicastInterfaces(); err == nil {
for _, c := range cands {
if c != nil && c.Flags&net.FlagLoopback == 0 {
ifi = c
ifaces = []*net.Interface{nil} // nil = system default
break
}
}
}
}
if len(ifaces) == 0 {
ifaces = []*net.Interface{nil}
}
// We only need one listening socket; use the first usable interface.
// net.ListenMulticastUDP binds to 0.0.0.0:1900 internally, so a single
// call is sufficient to receive multicast traffic on all interfaces on
// most platforms.
conn, err := net.ListenMulticastUDP("udp4", ifaces[0], group)
conn, err := net.ListenMulticastUDP("udp4", ifi, group)
if err != nil {
logger.Warn("SSDP: ListenMulticastUDP failed (try running as root or check firewall)", "err", err)
@@ -385,6 +416,315 @@ func primaryLANIP() (string, error) {
return "", fmt.Errorf("no usable LAN IPv4 address found")
}
// ----------------------------------------------------------------------------
// --media-dir loader
// ----------------------------------------------------------------------------
// loadTreeFromDir walks dir recursively and builds a single flat content folder
// from every audio file found, so an artist/album tree works. Album art for a
// track is, in order of preference: a sibling <basename>.<img>, then a
// cover.jpg/cover.png/folder.jpg in the track's own directory. Returns the tree
// and track count.
func loadTreeFromDir(dir, fallbackName string) (*dlnatest.Tree, int, error) {
rootClean := filepath.Clean(dir)
// Cache the resolved cover per directory so we read each album's folder.jpg
// once rather than for every track in it.
type cover struct {
data []byte
mime string
}
coverCache := map[string]cover{}
dirCover := func(d string) ([]byte, string) {
if c, ok := coverCache[d]; ok {
return c.data, c.mime
}
var c cover
for _, n := range []string{"cover.jpg", "cover.jpeg", "cover.png", "folder.jpg", "folder.png", "albumart.jpg", "albumart.png"} {
if b, err := os.ReadFile(filepath.Join(d, n)); err == nil {
c = cover{data: b, mime: imageMimeForExt(filepath.Ext(n))}
break
}
}
coverCache[d] = c
return c.data, c.mime
}
// Group tracks by their containing directory (preserving first-seen order),
// so each real album folder becomes its own browsable + playable container
// named after the directory, rather than one flat list named after --name.
type group struct {
dir string
items []*dlnatest.Item
}
groups := map[string]*group{}
var order []string
total := 0
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil //nolint:nilerr // skip unreadable entries and directories
}
mime := audioMimeForExt(filepath.Ext(path))
if mime == "" {
return nil // not an audio file we recognise
}
payload, rerr := os.ReadFile(path)
if rerr != nil {
return nil //nolint:nilerr // skip unreadable file, keep walking
}
trackDir := filepath.Dir(path)
base := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
// Prefer a per-track image sibling; fall back to the album-folder cover.
art, artMime := dirCover(trackDir)
for _, ae := range []string{".jpg", ".jpeg", ".png", ".webp"} {
if b, aerr := os.ReadFile(filepath.Join(trackDir, base+ae)); aerr == nil {
art = b
artMime = imageMimeForExt(ae)
break
}
}
g := groups[trackDir]
if g == nil {
g = &group{dir: trackDir}
groups[trackDir] = g
order = append(order, trackDir)
}
g.items = append(g.items, &dlnatest.Item{
Title: base,
Class: "object.item.audioItem.musicTrack",
Artist: artistForDir(trackDir, rootClean),
Album: albumTitle(trackDir, rootClean, fallbackName),
MimeType: mime,
Payload: payload,
ArtPayload: art,
ArtMime: artMime,
})
total++
return nil
})
if walkErr != nil {
return nil, 0, walkErr
}
if total == 0 {
return nil, 0, fmt.Errorf("no audio files (.mp3/.wav/.flac/.m4a/.ogg) found under %s", dir)
}
containers := make([]*dlnatest.Container, 0, len(order))
for ci, d := range order {
cid := strconv.Itoa(ci + 1)
g := groups[d]
for ti, it := range g.items {
it.ID = fmt.Sprintf("%s$%d", cid, ti)
it.ParentID = cid
}
containers = append(containers, &dlnatest.Container{
ID: cid,
ParentID: "0",
Title: albumTitle(d, rootClean, fallbackName),
Class: "object.container.storageFolder",
Children: g.items,
})
}
return &dlnatest.Tree{Containers: containers}, total, nil
}
// albumTitle returns the display name for a track directory: the directory's own
// name, or the fallback (the --name) when the tracks sit directly in the root.
func albumTitle(trackDir, root, fallback string) string {
if filepath.Clean(trackDir) == root {
return fallback
}
return filepath.Base(trackDir)
}
// artistForDir derives the artist from the directory above the album folder
// (e.g. <root>/<artist>/<album>/track.mp3 → "<artist>"). Falls back to
// "Unknown Artist" when there is no artist level (album directly under root, or
// tracks directly in root).
func artistForDir(trackDir, root string) string {
clean := filepath.Clean(trackDir)
if clean == root {
return "Unknown Artist"
}
parent := filepath.Dir(clean)
if parent == root {
return "Unknown Artist"
}
return filepath.Base(parent)
}
// audioMimeForExt maps an audio file extension to a MIME type, or "" if the
// extension is not a recognised audio format.
func audioMimeForExt(ext string) string {
switch strings.ToLower(ext) {
case ".mp3":
return "audio/mpeg"
case ".wav":
return "audio/x-wav"
case ".flac":
return "audio/flac"
case ".m4a", ".mp4":
return "audio/mp4"
case ".ogg":
return "audio/ogg"
}
return ""
}
// imageMimeForExt maps an image file extension to a MIME type.
func imageMimeForExt(ext string) string {
switch strings.ToLower(ext) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
}
return "application/octet-stream"
}
// ----------------------------------------------------------------------------
// HTTP access logging (debugging aid)
// ----------------------------------------------------------------------------
// statusRecorder captures the status code and byte count of a response.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
// withAccessLog logs every HTTP request the server handles. For ContentDirectory
// Browse POSTs it also surfaces the ObjectID and BrowseFlag so the speaker's
// browse sequence (and whether it ever resolves a track's metadata) is visible.
func withAccessLog(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
var browseAttrs []any
if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "ContentDir") {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<16))
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
browseAttrs = []any{
"objectID", between(string(body), "<ObjectID>", "</ObjectID>"),
"browseFlag", between(string(body), "<BrowseFlag>", "</BrowseFlag>"),
}
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
attrs := []any{
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"from", r.RemoteAddr,
"dur", time.Since(start).String(),
}
attrs = append(attrs, browseAttrs...)
logger.Info("HTTP", attrs...)
})
}
// between returns the text between the first occurrence of openTag and the next
// closeTag, or "" if not found. Used for lightweight SOAP field extraction in logs.
func between(s, openTag, closeTag string) string {
i := strings.Index(s, openTag)
if i < 0 {
return ""
}
i += len(openTag)
j := strings.Index(s[i:], closeTag)
if j < 0 {
return ""
}
return s[i : i+j]
}
// interfaceForIP returns the UP, multicast-capable interface that owns the given
// IPv4 address, or nil if none is found.
func interfaceForIP(ip string) *net.Interface {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
for i := range ifaces {
iface := &ifaces[i]
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagMulticast == 0 {
continue
}
addrs, aerr := iface.Addrs()
if aerr != nil {
continue
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if v4 := ipNet.IP.To4(); v4 != nil && v4.String() == ip {
return iface
}
}
}
}
return nil
}
// extractHeader extracts a header value from a raw HTTP-style SSDP message.
// Key comparison is case-insensitive.
func extractHeader(msg, key string) string {
+5 -1
View File
@@ -14,7 +14,11 @@ var version = "dev"
func init() {
if info, ok := debug.ReadBuildInfo(); ok {
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Only fall back to build info when the version was not injected via
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
// This keeps an explicitly stamped release version from being clobbered
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
}
+15 -2
View File
@@ -318,7 +318,7 @@ func removePandoraAccount(c *cli.Context) error {
func addStoredMusicAccount(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
stClient, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
@@ -340,13 +340,26 @@ func addStoredMusicAccount(c *cli.Context) error {
fmt.Printf(" Display Name: %s\n", displayName)
fmt.Printf(" Type: UPnP/DLNA Media Server\n")
err = client.AddStoredMusicAccount(user, displayName)
err = stClient.AddStoredMusicAccount(user, displayName)
if err != nil {
return fmt.Errorf("failed to add network music library: %w", err)
}
PrintSuccess("Network music library added successfully")
// Send a sourcesUpdated nudge so the speaker re-fetches its account list and
// registers the new source without requiring a power-cycle. This is
// best-effort: a failure here does not abort the command.
if info, infoErr := stClient.GetDeviceInfo(); infoErr == nil && info != nil && info.DeviceID != "" {
if nudgeErr := stClient.NotifySourcesUpdated(info.DeviceID); nudgeErr == nil {
fmt.Println(" Sent a sources refresh to the speaker (no reboot needed).")
} else {
fmt.Println(" Warning: could not send sources refresh; you may need to power-cycle the speaker for the new source to register.")
}
} else {
fmt.Println(" Warning: could not retrieve device ID; you may need to power-cycle the speaker for the new source to register.")
}
// Show next steps
fmt.Printf("\n💡 Next Steps:\n")
fmt.Printf(" • Check available sources: soundtouch-cli --host %s source list\n", clientConfig.Host)
+53 -9
View File
@@ -538,6 +538,53 @@ func setupSSHCheckCmd() *cli.Command {
}
}
// runEnableSSHInjection runs the port-17000 SSH-enable injection over telnet,
// printing the device transcript as it goes. With fullConfig it sends the
// #515 sequence (all four config URLs with the injection on margeServerUrl, not
// just envswitch) and reboots afterwards; otherwise it sends the single-
// envswitch default that fires on the speaker's next boseurls check.
func runEnableSSHInjection(m *setup.Manager, host, serviceURL string, fullConfig bool) error {
var (
logs string
err error
)
if fullConfig {
fmt.Printf("Enabling SSH on %s via telnet :17000 (full #515 sequence: all four config URLs with the injection on margeServerUrl, then reboot)...\n", host)
logs, err = m.EnableSSHViaTelnetFullConfig(host, serviceURL)
} else {
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", host)
logs, err = m.EnableSSHViaTelnet(host, serviceURL)
}
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
if !fullConfig {
return nil
}
fmt.Println("Rebooting the speaker to apply the new configuration...")
rlogs, rerr := m.Reboot(host, setup.RebootMethodTelnet)
if rlogs != "" {
fmt.Print(rlogs)
}
if rerr != nil {
PrintError(rerr.Error())
return rerr
}
return nil
}
func setupEnableSSHCmd() *cli.Command {
return &cli.Command{
Name: "enable-ssh",
@@ -556,6 +603,11 @@ func setupEnableSSHCmd() *cli.Command {
Value: 90 * time.Second,
Usage: "How long to wait for sshd (:22) after the envswitch injection (it runs on the speaker's next boseurls check, ~60s)",
},
&cli.BoolFlag{
Name: "full-config",
Usage: "For stubborn devices (ST Portable, CineMate 520) where the default single-envswitch injection is accepted but sshd never starts: " +
"replicate the #515 manual sequence — write all four sys configuration URL keys with the SSH-enable injection on margeServerUrl (not just envswitch), then reboot",
},
&cli.BoolFlag{
Name: "no-reset-urls",
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
@@ -589,15 +641,7 @@ func setupEnableSSHCmd() *cli.Command {
serviceURL = "https://aftertouch.invalid"
}
fmt.Printf("Enabling SSH on %s via telnet :17000 (runs on the speaker's next boseurls check, up to ~60s)...\n", cfg.Host)
logs, err := m.EnableSSHViaTelnet(cfg.Host, serviceURL)
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
if err := runEnableSSHInjection(m, cfg.Host, serviceURL, c.Bool("full-config")); err != nil {
return err
}
+6 -2
View File
@@ -73,8 +73,12 @@ func getFlagName(flag cli.Flag) string {
// updateBuildInfo extracts version information from debug.BuildInfo and updates package variables
func updateBuildInfo() {
if info, ok := debug.ReadBuildInfo(); ok {
// Get version from module info
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Get version from module info. Only fall back to build info when the
// version was not injected via -ldflags (i.e. still the "dev" default,
// e.g. `go install …@vX.Y.Z`). This keeps an explicitly stamped release
// version from being clobbered by a VCS pseudo-version (e.g. v0.0.0-…
// from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
+11 -8
View File
@@ -4,8 +4,9 @@
// remote AfterTouch service via --service-url, which is why it stays useful
// when soundtouch-service runs off-LAN (e.g. in the cloud).
//
// It was previously named soundtouch-web; that name is still published as a
// transitional alias and will be dropped in a future release.
// It was previously named soundtouch-web; that name is no longer published.
// If you still run the binary under the old name, it prints a rename notice
// and otherwise behaves identically.
package main
import (
@@ -38,7 +39,11 @@ func updateBuildInfo() {
repoURL = "https://" + info.Main.Path
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Only fall back to build info when the version was not injected via
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
// This keeps an explicitly stamped release version from being clobbered
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
@@ -56,9 +61,8 @@ func updateBuildInfo() {
}
// warnIfInvokedAsWeb prints a one-line deprecation notice when the binary is
// run under its old name (soundtouch-web). The soundtouch-web artifact is a
// transitional alias built from this same source; this nudges operators to
// switch to soundtouch-player before the alias is dropped.
// run under its old name (soundtouch-web). That name is no longer published,
// but anyone who renamed the binary still gets nudged to soundtouch-player.
func warnIfInvokedAsWeb() {
if len(os.Args) == 0 {
return
@@ -67,8 +71,7 @@ func warnIfInvokedAsWeb() {
name := filepath.Base(os.Args[0])
if name == "soundtouch-web" || name == "soundtouch-web.exe" {
log.Println("notice: 'soundtouch-web' has been renamed to 'soundtouch-player'. " +
"This name is a transitional alias and will stop being published in a future release; " +
"please switch to 'soundtouch-player'.")
"The 'soundtouch-web' name is no longer published; please switch to 'soundtouch-player'.")
}
}
+11 -1
View File
@@ -50,7 +50,11 @@ func updateBuildInfo() {
repoURL = "https://" + info.Main.Path
}
if info.Main.Version != "" && info.Main.Version != "(devel)" {
// Only fall back to build info when the version was not injected via
// -ldflags (i.e. still the "dev" default, e.g. `go install …@vX.Y.Z`).
// This keeps an explicitly stamped release version from being clobbered
// by a VCS pseudo-version (e.g. v0.0.0-… from a shallow checkout).
if version == "dev" && info.Main.Version != "" && info.Main.Version != "(devel)" {
version = info.Main.Version
}
@@ -481,6 +485,12 @@ func main() {
if persisted.ServerURL == "" {
log.Printf("Creating default settings.json in %s", sanitizeLog(config.dataDir))
log.Printf("Data directory %s looks empty (first run). If you did NOT expect this "+
"(e.g. after recreating a Docker container), your previous settings, datastore and "+
"CA were not persisted; mount a persistent volume at the data dir (Docker: "+
"-v <volume>:/app/data) so device state and the CA survive restarts. A lost CA "+
"forces re-migrating speakers and re-trusting the new CA.",
sanitizeLog(config.dataDir))
persisted = createDefaultSettings(ds, config)
}
@@ -225,7 +225,7 @@ These show up in `getpdo`, `network status`, and SSH-side hostnames. Useful for
- **Firmware 1.x7.x** (S1 era): everything — `help`, `remote_services on`, full `scm`, and an in-shell login prompt. `flarn2006` documents the original Linux insides.
- **Firmware 8.x14.x** (S2 era): `remote_services on` removed; `network`, `sys`, `envswitch`, `getpdo` still present. `local_services on` works on some Wave/SA-5 models.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target.
- **Firmware 27.x** (S5/S6 era — the long-lived "frozen" build that survived through EOS): `help`, `remote_services on`, and `sys ver` removed in some builds; `sys configuration …` and `envswitch …` confirmed working on ST 10, ST 20, ST 300, Wave III, Wave IV. **This is the firmware our migration targets**. The Portable on more recent firmware drops further commands and is the hardest target; on the ST Portable (Series I, FW `27.0.6.46330.5043500`) and some CineMate 520 units the SSH-enable injection persists but `sshd` does not start via the default path, which is what `setup enable-ssh --full-config` addresses (see "What we use to enable SSH" above).
S5 enumerated the **top-level command roots** that don't return "Command not found" on a vanilla ST 10 (`rhino`) running `27.0.6.46330.5043500`:
@@ -265,6 +265,38 @@ Reboot is **not** part of these sequences — it stays a user-initiated action v
---
## What we use to enable SSH (`setup enable-ssh`, #471)
To open SSH on a speaker that has never had it (no USB recovery), the CLI abuses the boseurls value as a command-injection vehicle: when the device next parses it, the appended shell snippet touches the `remote_services` marker and starts `sshd`. The injected suffix is:
```
;touch /tmp/remote_services;/etc/init.d/sshd start
```
**Default path** (`soundtouch-cli setup enable-ssh`) writes that injection only via the persistence layer, then waits for `:22`:
```
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/update"
```
This is field-confirmed on the Wireless Link Adapter and on the CineMate 520 `lisa` variant (FW 27.0.6).
**`--full-config` path** (`soundtouch-cli setup enable-ssh --full-config`) is for devices where the default injection is *accepted and persisted* (`getpdo` confirms the value) but `sshd` never comes up, so `:22` stays "Connection refused". It mirrors the manual telnet sequence @Henri-be confirmed by hand on issue #515: it puts the injection on the runtime `sys configuration margeServerUrl` key as well as `envswitch`, writes all four URL keys, then reboots so the device re-parses the config at boot:
```
sys configuration bmxRegistryUrl "<serverURL>/bmx/registry/v1/services"
sys configuration statsServerUrl "<serverURL>"
sys configuration margeServerUrl "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start"
sys configuration swUpdateUrl "<serverURL>/updates/soundtouch"
envswitch boseurls set "<serverURL>;touch /tmp/remote_services;/etc/init.d/sshd start" "<serverURL>/updates/soundtouch"
getpdo CurrentSystemConfiguration
sys reboot
```
**Which devices need `--full-config`:** observed on the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** (#515) and on some **CineMate 520** units where the default path leaves `sshd` down. The structural differences from the default path that appear to matter are (1) the injection riding `sys configuration margeServerUrl`, not just `envswitch`, and (2) the explicit `sys reboot`. The `--full-config` automation is **candidate behaviour awaiting reporter confirmation** — the manual sequence is confirmed working on the ST Portable, but the flag that automates it has not yet been re-confirmed on hardware. Not every device responds even to the manual sequence (some ST10 and CineMate 520 units never start `sshd` over telnet at all and need the serial / U-Boot route).
---
## Out of scope here, but worth recording
- **Setup-mode WiFi onboarding via 192.0.2.1.** The community uses this to add a fresh device to a network without the Bose app. Our `soundtouch-service` does not currently automate this, but `network wifi profiles add` is the entry point if we ever do.
+6 -2
View File
@@ -21,13 +21,17 @@ Most SoundTouch devices run a modified Linux distribution. Accessing these logs
Community research (SoundCork Issue #112) has identified a "backdoor" to enable developer services:
1. **USB Method**:
1. **CLI Method (recommended, no USB needed)**:
- `soundtouch-cli --host <device-ip> setup enable-ssh` drives the port-17000 diagnostic shell to inject the `remote_services` marker and start `sshd`, then waits for `:22`. This is the #471 bootstrap; it needs no prior SSH and no USB stick.
- If the command is accepted (the device persists it, confirmed by `getpdo`) but `sshd` never comes up and `:22` stays "Connection refused", retry with `--full-config`. That variant mirrors the manual telnet sequence confirmed on issue #515: it puts the injection on `sys configuration margeServerUrl` as well as `envswitch`, writes all four URL keys, and reboots.
- **`--full-config` is meant for:** the **SoundTouch Portable (Series I, model 412540, FW `27.0.6.46330.5043500`)** and some **CineMate 520** units, where the default single-`envswitch` path leaves `sshd` down. The default path is sufficient on the Wireless Link Adapter and the CineMate 520 `lisa` variant. Some units (e.g. certain ST10 and CineMate 520 firmwares) do not respond to either path and need the serial / U-Boot console route instead. See [TELNET-COMMAND-REFERENCE.md](../analysis/TELNET-COMMAND-REFERENCE.md#what-we-use-to-enable-ssh-setup-enable-ssh-471) for the exact commands and current confirmation status.
2. **USB Method**:
- Format a USB stick to **FAT32**.
- Create an empty file named `remote_services` (no extension) in the root of the USB stick.
- Insert the stick into the SoundTouch device.
- Reboot the device (power cycle).
- On some models, you may need to hold **4** and **Volume -** on the device while powering on to force a USB check.
2. **TAP Command (Legacy)**:
3. **TAP Command (Legacy)**:
- On older firmware versions, you can connect to port 17000 via Telnet and issue the command: `remote_services on`.
### Making Root Access Persistent
@@ -34,13 +34,13 @@ sudo bash install.sh
```
The installer detects your Pi's architecture (armv7, arm64, or amd64), downloads
the binary, creates a `soundtouch` system user, and registers a systemd unit that
starts on boot.
the latest release binary, creates a `soundtouch` system user, and registers a
systemd unit that starts on boot.
To install a specific version:
To pin a specific version instead of the latest:
```bash
sudo bash install.sh v0.107.0
sudo bash install.sh v0.111.3
```
Check that the service is running:
@@ -66,13 +66,28 @@ docker run -d \
--name aftertouch \
--network host \
-e SERVER_URL=http://192.0.2.10:8000 \
-v aftertouch-data:/data \
-v aftertouch-data:/app/data \
ghcr.io/gesellix/bose-soundtouch:latest
```
Replace `192.0.2.10` with the host machine's LAN IP. The `--network host` flag
is required so AfterTouch can reach the speakers and respond to mDNS discovery.
> **Persist the data directory.** The container stores everything stateful under
> `/app/data` (`DATA_DIR`): the datastore, `settings.json`, and the service CA.
> Mount a volume there (`-v <volume>:/app/data`, as above) or this state is lost
> when the container is recreated. Losing the CA forces you to re-migrate every
> speaker and re-trust the new CA, so back this volume up before upgrading.
> **Windows / macOS (Docker Desktop):** `--network host` does not work the same
> way as on Linux, so publish the ports explicitly instead, e.g.
> `-p 8000:8000 -p 8443:8443`. mDNS discovery across the Docker Desktop network
> boundary is unreliable; add speakers by IP in the Devices tab. If you also use
> DNS interception (so the speaker resolves Bose hostnames to AfterTouch), you
> additionally need to publish the DNS port (`-p 53:53/udp -p 53:53/tcp`) and
> make AfterTouch reachable on `:443` (the hardcoded Bose hosts are plain HTTPS),
> e.g. `-p 443:8443`. Keep the same `-v <volume>:/app/data` mount.
---
## Step 2 — Note your host's LAN IP and open the Admin UI
@@ -176,12 +191,12 @@ open **`http://<host-ip>:8080`** in your browser (default port 8080).
### Installing soundtouch-player on a Raspberry Pi
`install.sh` only installs `soundtouch-service`. Use the dedicated
`install-web.sh` script to add soundtouch-player:
`install-player.sh` script to add soundtouch-player:
```bash
curl -fsSL -o install-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
sudo bash install-web.sh
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
```
For configuration, service management, updates, and removal see the
@@ -263,7 +278,7 @@ curl -s http://192.0.2.1:8090/presets
```bash
sudo bash install.sh # updates to latest release
sudo bash install.sh v0.107.0 # updates to a specific version
sudo bash install.sh v0.111.3 # updates to a specific version
```
The installer stops the service, downloads the new binary, and restarts
@@ -81,14 +81,15 @@ currently running binary, and starts the service:
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
```
To target a specific version instead of the default:
By default this installs the **latest release** — the script resolves it from
GitHub's `releases/latest` redirect. To target a specific version instead:
```bash
# Via environment variable (works with pipe-to-sh)
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# Via command-line flag (pass args after sh -s --)
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
```
Verify the installed version:
@@ -97,7 +98,7 @@ Verify the installed version:
wget -qO- http://localhost:8000/health
```
The JSON response should include `"version":"v0.107.0"` (or whichever
The JSON response should include `"version":"v0.111.3"` (or whichever
version you installed).
---
@@ -185,13 +186,13 @@ next reboot — which is fine for a one-time setup run):
cd /tmp
curl -L --fail -o soundtouch-cli \
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.107.0/soundtouch-cli-v0.107.0-linux-armv7
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.111.3/soundtouch-cli-v0.111.3-linux-armv7
chmod +x soundtouch-cli
/tmp/soundtouch-cli --version
```
Replace `v0.107.0` with the version you installed.
Replace `v0.111.3` with the version you installed.
---
@@ -304,12 +305,12 @@ older artefacts to keep `/mnt/nv` free:
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# Update to a specific version — three equivalent forms
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
sh install.sh --version 0.107.0
sh install.sh --version 0.111.3
```
**Rollback:** the installer keeps a `.backup` file alongside the binary:
+50 -17
View File
@@ -6,13 +6,16 @@ host) using the provided installer scripts.
Two scripts are available, one per binary:
| Script | Binary | Role | Default port |
|------------------|----------------------|-------------------------------------|--------------|
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
| `install-web.sh` | `soundtouch-player` | Browser control panel | 8080 |
| Script | Binary | Role | Default port |
|---------------------|----------------------|-------------------------------------|--------------|
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — always-on | 80 / 443 |
| `install-player.sh` | `soundtouch-player` | Browser control panel | 8080 |
Both auto-detect CPU architecture (armv7 / arm64 / amd64), create a `soundtouch`
system user, and install a systemd unit. They are safe to re-run for updates.
Run without a version argument, they install the **latest release** (resolved
from GitHub's `releases/latest` redirect); pass a tag to pin a specific version.
Each installer has a matching uninstaller (`uninstall.sh`, `uninstall-player.sh`).
For a complete install-through-migration walkthrough see
[EXTERNAL-HOST-WALKTHROUGH.md](EXTERNAL-HOST-WALKTHROUGH.md).
@@ -34,14 +37,14 @@ sudo bash install.sh
Install a specific version:
```bash
sudo bash install.sh v0.107.0
sudo bash install.sh v0.111.3
```
Override defaults at install time:
```bash
sudo \
VERSION=v0.107.0 \
VERSION=v0.111.3 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
@@ -99,7 +102,7 @@ journalctl -u soundtouch-service -b # this boot only
```bash
sudo bash install.sh # update to latest release
sudo bash install.sh v0.107.0 # update to a specific version
sudo bash install.sh v0.111.3 # update to a specific version
```
The script stops the service, downloads the new binary (backs up the old one to
@@ -107,13 +110,30 @@ The script stops the service, downloads the new binary (backs up the old one to
### Removal
Use the uninstaller, which stops and disables the service and removes the unit,
binary, and config. Your data directory is **preserved** by default:
```bash
curl -fsSL -o uninstall.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall.sh
sudo bash uninstall.sh # keep /var/lib/soundtouch-service
sudo bash uninstall.sh --purge # also delete the data directory
```
The `soundtouch:soundtouch` user/group is removed only once no other
`soundtouch-*` install remains on the host.
Prefer to do it by hand? The equivalent manual steps are:
```bash
sudo systemctl disable --now soundtouch-service
sudo rm /etc/systemd/system/soundtouch-service.service
sudo rm -rf /etc/soundtouch-service
sudo rm -rf /var/lib/soundtouch-service
sudo rm /usr/local/bin/soundtouch-service
sudo systemctl daemon-reload
# Datastore (presets, device registrations, certs) — delete only if you are
# sure you no longer need it:
sudo rm -rf /var/lib/soundtouch-service
```
---
@@ -126,24 +146,24 @@ data and can be stopped or restarted at any time without data loss.
### Installation
```bash
curl -fsSL -o install-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
sudo bash install-web.sh
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
```
Install a specific version:
```bash
sudo bash install-web.sh v0.107.0
sudo bash install-player.sh v0.111.3
```
Override defaults at install time:
```bash
sudo \
VERSION=v0.107.0 \
VERSION=v0.111.3 \
HTTP_PORT=8081 \
bash install-web.sh
bash install-player.sh
```
Once running, open **`http://<pi-ip>:8080`** in a browser.
@@ -228,12 +248,25 @@ journalctl -u soundtouch-player -f
### Updates
```bash
sudo bash install-web.sh # update to latest release
sudo bash install-web.sh v0.107.0 # update to a specific version
sudo bash install-player.sh # update to latest release
sudo bash install-player.sh v0.111.3 # update to a specific version
```
### Removal
Use the uninstaller:
```bash
curl -fsSL -o uninstall-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-player.sh
sudo bash uninstall-player.sh
```
The `soundtouch:soundtouch` user/group is removed only once no other
`soundtouch-*` install remains on the host.
Prefer to do it by hand? The equivalent manual steps are:
```bash
sudo systemctl disable --now soundtouch-player
sudo rm /etc/systemd/system/soundtouch-player.service
@@ -258,7 +291,7 @@ Override if needed:
```bash
sudo ARCH_ASSET=linux-arm64 bash install.sh
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
sudo ARCH_ASSET=linux-arm64 bash install-player.sh
```
---
@@ -465,6 +465,30 @@ 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.
### ❌ Radio sources never activate after an in-place migration {#radio-sources-after-migration}
**Symptoms:**
- The speaker was migrated **in place** (not factory-reset first) and is reachable; account-bound sources (for example a music-streaming login) work and presets for them play.
- **Every** radio-type source fails: selecting any `LOCAL_INTERNET_RADIO`, `TUNEIN`, or `RADIO_BROWSER` content returns `1005`, including the Health tab's "Play ding" test.
- `curl http://<speaker-ip>:8090/sources` lists no radio source types at all.
- The Health check warns that the speaker is "missing N source type(s) the service advertises".
- The entries are present on disk in **both** the service-side `Sources.xml` **and** the speaker's own `/mnt/nv/BoseApp-Persistence/1/Sources.xml`, yet a reboot and a `sourcesUpdated` notification do not make them activate.
**Cause:**
Not fully understood. After an in-place migration the firmware does not activate the radio source **types** in its runtime, even though the entries exist in the speaker's persisted `Sources.xml`. This is firmware behaviour and we have not confirmed the exact trigger. (If you hit this, an encrypted diagnostic report taken **before** you reset the speaker is very helpful, and now includes the speaker's on-device `Sources.xml`. See the "Getting More Help" section below.)
**Workaround (confirmed by users):**
Factory reset the speaker, then re-migrate it:
1. Factory reset (on most models: hold `1` + `` for ~10 seconds).
2. Reconnect the speaker to your network.
3. Re-migrate it in AfterTouch.
After this the radio sources activate normally. Note the factory reset rewrites the speaker's `Sources.xml` to defaults, so any **account-bound** source (for example a music-streaming login) has to be re-added afterwards; your presets for it come back once the source is present again.
## 🔊 **Volume & Audio Issues**
### ❌ "Volume control not working"
@@ -338,6 +338,19 @@ Configures the clock display.
### POST /speaker ✅ **Implemented**
Plays TTS messages or URL content for notifications (ST-10 Series only).
> **Requires DNS interception.** Before playing a `play_info` notification the
> speaker validates the `app_key` by calling `GET /v1/auth` against a hardcoded
> Bose host (`audionotification.api.bosecm.com`, on some firmware the
> `...dev...` variant). After the cloud shutdown that host no longer exists, so
> unless the speaker resolves Bose hostnames through AfterTouch (DNS server +
> the `/etc/resolv.conf` hook, so `*.api.bosecm.com` points at AfterTouch, which
> answers `/v1/auth`), the request hangs and returns
> `ALLEGROWEBSERVER_TIMEOUT` (error `1046`) after ~60s. If you cannot use DNS
> interception, play the clip via the `LOCAL_INTERNET_RADIO` path instead (the
> "radio" method used by the web player's TTS): it needs no `app_key` and no DNS
> redirection, but it replaces the current source rather than ducking and
> resuming it.
**TTS Request XML:**
```xml
<play_info>
@@ -43,6 +43,8 @@ curl -v -X POST http://<speaker-ip>:8090/notification \
Replace `<speaker-ip>` with your speaker's IP address and `<deviceID>` with its device ID (visible in `/info`). After this call the speaker re-fetches its sources from the service. Step 2 (source-type registration) still requires a reboot.
> **If a reboot doesn't help after an in-place migration:** on some speakers the radio source types (`LOCAL_INTERNET_RADIO`, `TUNEIN`, `RADIO_BROWSER`) never activate after an in-place migration, even though the entries are present in the device-local `Sources.xml` and you have rebooted and sent `sourcesUpdated`. The cause isn't fully understood; the confirmed remedy is a factory reset + re-migrate. See [Troubleshooting: Radio sources never activate after an in-place migration](../guides/TROUBLESHOOTING.md#radio-sources-after-migration).
### Search for stations
- Go to https://www.radio-browser.info and find a station you like.
+10 -10
View File
@@ -8,16 +8,16 @@ require (
github.com/go-chi/chi/v5 v5.2.5
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/mdns v1.0.6
github.com/hashicorp/mdns v1.0.7
github.com/miekg/dns v1.1.72
github.com/russross/blackfriday/v2 v2.1.0
github.com/sergi/go-diff v1.4.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/term v0.43.0
golang.org/x/crypto v0.53.0
golang.org/x/net v0.56.0
golang.org/x/term v0.44.0
)
require (
@@ -31,10 +31,10 @@ require (
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.46.0 // indirect
)
+20 -86
View File
@@ -33,14 +33,13 @@ github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/mdns v1.0.6 h1:SV8UcjnQ/+C7KeJ/QeVD/mdN2EmzYfcGfufcuzxfCLQ=
github.com/hashicorp/mdns v1.0.6/go.mod h1:X4+yWh+upFECLOki1doUPaKpgNQII9gy4bUdCYKNhmM=
github.com/hashicorp/mdns v1.0.7 h1:yWoQVMW5JOiDxQnIUcm3IDt0kCjf3TuXHDbdEKPsbAY=
github.com/hashicorp/mdns v1.0.7/go.mod h1:yjuhYhZyPDqXXL48xC7cdpGwGUMwu7OViDmsuT5COvg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
@@ -62,101 +61,36 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+21 -5
View File
@@ -1338,7 +1338,13 @@ func (c *Client) AddToZone(deviceID, ipAddress string) error {
return c.SetZone(zoneRequest)
}
// RemoveFromZone removes a device from the current zone
// RemoveFromZone removes a device from the current zone.
//
// It uses the dedicated /removeZoneSlave endpoint rather than rebuilding the
// zone with /setZone and the remaining members: /setZone does not drop a member
// from a multi-member zone (the speaker only goes standalone when the resulting
// member set is empty), so a setZone rebuild silently fails to remove one of
// several members. See #511.
func (c *Client) RemoveFromZone(deviceID string) error {
// Get current zone configuration
currentZone, err := c.GetZone()
@@ -1346,11 +1352,21 @@ func (c *Client) RemoveFromZone(deviceID string) error {
return fmt.Errorf("failed to get current zone: %w", err)
}
// Convert to zone request and remove member
zoneRequest := currentZone.ToZoneRequest()
zoneRequest.RemoveMember(deviceID)
if currentZone.IsStandalone() {
return nil // nothing to remove
}
return c.SetZone(zoneRequest)
// Carry the member's IP (as the speaker expects) when we know it.
slaveIP := ""
for i := range currentZone.Members {
if currentZone.Members[i].DeviceID == deviceID {
slaveIP = currentZone.Members[i].IP
break
}
}
return c.RemoveZoneSlave(currentZone.Master, deviceID, slaveIP)
}
// DissolveZone dissolves the current zone, making all devices standalone
+30 -6
View File
@@ -1,6 +1,7 @@
package client
import (
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -295,12 +296,15 @@ func TestClient_RemoveFromZone(t *testing.T) {
getZoneCalled := false
setZoneCalled := false
var removeBody string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
if r.URL.Path == "/getZone" && r.Method == http.MethodGet {
switch {
case r.URL.Path == "/getZone" && r.Method == http.MethodGet:
getZoneCalled = true
// Return existing zone with members
// Return existing zone with two members.
response := `<?xml version="1.0" encoding="UTF-8" ?>
<zone master="ABCD1234EFGH">
<member ipaddress="192.0.2.11">EFGH5678IJKL</member>
@@ -309,11 +313,16 @@ func TestClient_RemoveFromZone(t *testing.T) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(response))
} else if r.URL.Path == "/setZone" && r.Method == http.MethodPost {
case r.URL.Path == "/removeZoneSlave" && r.Method == http.MethodPost:
b, _ := io.ReadAll(r.Body)
removeBody = string(b)
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/setZone" && r.Method == http.MethodPost:
setZoneCalled = true
w.WriteHeader(http.StatusOK)
} else {
default:
w.WriteHeader(http.StatusNotFound)
}
}))
@@ -321,6 +330,9 @@ func TestClient_RemoveFromZone(t *testing.T) {
client := createTestClient(server.URL)
// Removing one of two members must target that member via /removeZoneSlave,
// not rebuild the zone via /setZone (which does not drop a member from a
// multi-member zone). Regression for #511.
err := client.RemoveFromZone("EFGH5678IJKL")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
@@ -330,8 +342,20 @@ func TestClient_RemoveFromZone(t *testing.T) {
t.Error("Expected GetZone to be called")
}
if !setZoneCalled {
t.Error("Expected SetZone to be called")
if setZoneCalled {
t.Error("RemoveFromZone must not use /setZone to drop a member from a multi-member zone")
}
if !strings.Contains(removeBody, "EFGH5678IJKL") {
t.Errorf("removeZoneSlave body should target the member, got: %s", removeBody)
}
if !strings.Contains(removeBody, `master="ABCD1234EFGH"`) {
t.Errorf("removeZoneSlave body should name the master, got: %s", removeBody)
}
if !strings.Contains(removeBody, `ipaddress="192.0.2.11"`) {
t.Errorf("removeZoneSlave body should carry the member IP from the zone, got: %s", removeBody)
}
}
+189 -61
View File
@@ -13,14 +13,20 @@
package dlnatest
import (
"bytes"
"encoding/xml"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"time"
)
// serveModTime is a fixed modification time used for ServeContent so that
// range requests and caching headers behave deterministically.
var serveModTime = time.Unix(1136214245, 0)
// ----------------------------------------------------------------------------
// Content tree model
// ----------------------------------------------------------------------------
@@ -45,6 +51,12 @@ type Item struct {
MimeType string
DurSec float64 // duration in seconds
Payload []byte // raw audio bytes served at /MediaItems/<ID>.<ext>
// ArtPayload, when non-empty, is album-art image bytes served at
// /AlbumArt/<ID>.<ext> and advertised in DIDL-Lite via <upnp:albumArtURI>.
// ArtMime is the art image MIME type (e.g. "image/jpeg").
ArtPayload []byte
ArtMime string
}
// mediaExt returns the file extension for this item's MIME type.
@@ -52,11 +64,35 @@ func (it *Item) mediaExt() string {
switch it.MimeType {
case "audio/x-wav", "audio/wav":
return "wav"
case "audio/mpeg":
return "mp3"
case "audio/flac", "audio/x-flac":
return "flac"
case "audio/mp4", "audio/m4a", "audio/x-m4a":
return "m4a"
case "audio/ogg":
return "ogg"
default:
return "bin"
}
}
// artExt returns the file extension for an album-art MIME type.
func artExt(mime string) string {
switch mime {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/gif":
return "gif"
default:
return "img"
}
}
// Tree is the in-memory content tree. Root containers are stored by ID.
type Tree struct {
Containers []*Container // ordered; first container is the default music folder
@@ -75,26 +111,30 @@ func DefaultTree() *Tree {
Class: "object.container.storageFolder",
Children: []*Item{
{
ID: "1$4$0",
ParentID: "1$4",
Title: "track01",
Class: "object.item.audioItem.musicTrack",
Artist: "Test Artist",
Album: "Test Album",
MimeType: "audio/x-wav",
DurSec: 1.0,
Payload: track01,
ID: "1$4$0",
ParentID: "1$4",
Title: "track01",
Class: "object.item.audioItem.musicTrack",
Artist: "Test Artist",
Album: "Test Album",
MimeType: "audio/x-wav",
DurSec: 1.0,
Payload: track01,
ArtPayload: tinyPNG,
ArtMime: "image/png",
},
{
ID: "1$4$1",
ParentID: "1$4",
Title: "track02",
Class: "object.item.audioItem.musicTrack",
Artist: "Test Artist",
Album: "Test Album",
MimeType: "audio/x-wav",
DurSec: 1.0,
Payload: track02,
ID: "1$4$1",
ParentID: "1$4",
Title: "track02",
Class: "object.item.audioItem.musicTrack",
Artist: "Test Artist",
Album: "Test Album",
MimeType: "audio/x-wav",
DurSec: 1.0,
Payload: track02,
ArtPayload: tinyPNG,
ArtMime: "image/png",
},
},
}
@@ -186,6 +226,7 @@ func (s *Server) HTTPHandler() http.Handler {
mux.HandleFunc("/ctl/ContentDir", s.serveContentDir)
mux.HandleFunc("/icons/sm.png", s.serveIcon)
mux.HandleFunc("/MediaItems/", s.serveMediaItem)
mux.HandleFunc("/AlbumArt/", s.serveAlbumArt)
return mux
}
@@ -322,18 +363,24 @@ func (s *Server) serveContentDir(w http.ResponseWriter, r *http.Request) {
var total int
switch objectID {
case "0":
// Root: return containers.
didl, total = s.browseRoot(startIndex, reqCount)
default:
// Try as a container ID.
if c := s.tree.containerByID(objectID); c != nil {
didl, total = s.browseContainer(c, startIndex, reqCount, base)
} else {
// Unknown object: return empty result.
didl = emptyDIDL()
total = 0
if req.Body.Browse.BrowseFlag == "BrowseMetadata" {
// Metadata for a single object (the speaker resolves a track's <res>
// this way before playing it).
didl, total = s.browseMetadata(objectID, base)
} else {
switch objectID {
case "0":
// Root: return containers.
didl, total = s.browseRoot(startIndex, reqCount)
default:
// Try as a container ID.
if c := s.tree.containerByID(objectID); c != nil {
didl, total = s.browseContainer(c, startIndex, reqCount, base)
} else {
// Unknown object: return empty result.
didl = emptyDIDL()
total = 0
}
}
}
@@ -391,6 +438,44 @@ func (s *Server) browseRoot(start, count int) (string, int) {
return b.String(), total
}
// didlOpen is the opening tag (with namespaces) shared by all DIDL-Lite results.
const didlOpen = `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ` +
`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`
// writeItemDIDL writes a single DIDL-Lite <item> (title, artist/album, class,
// optional albumArtURI, and the <res> media URL) into b.
func writeItemDIDL(b *strings.Builder, it *Item, base string) {
size := len(it.Payload)
dur := formatDuration(it.DurSec)
resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())
_, _ = fmt.Fprintf(b, `<item id=%s parentID=%s restricted="1">`, xmlAttr(it.ID), xmlAttr(it.ParentID))
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
if it.Artist != "" {
b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
}
if it.Album != "" {
b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
}
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
if len(it.ArtPayload) > 0 {
artURL := fmt.Sprintf("%s/AlbumArt/%s.%s", base, urlPathEsc(it.ID), artExt(it.ArtMime))
b.WriteString(`<upnp:albumArtURI>` + xmlEsc(artURL) + `</upnp:albumArtURI>`)
}
_, _ = fmt.Fprintf(b,
`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
)
b.WriteString(`</item>`)
}
// browseContainer returns DIDL-Lite for the items inside a container.
func (s *Server) browseContainer(c *Container, start, count int, base string) (string, int) {
items := c.Children
@@ -399,36 +484,10 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s
var b strings.Builder
b.WriteString(`<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" `)
b.WriteString(`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" `)
b.WriteString(`xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" `)
b.WriteString(`xmlns:dlna="urn:schemas-dlna-org:metadata-1-0/">`)
b.WriteString(didlOpen)
for _, it := range pageItems {
size := len(it.Payload)
dur := formatDuration(it.DurSec)
resURL := fmt.Sprintf("%s/MediaItems/%s.%s", base, urlPathEsc(it.ID), it.mediaExt())
_, _ = fmt.Fprintf(&b,
`<item id=%s parentID=%s restricted="1">`,
xmlAttr(it.ID), xmlAttr(it.ParentID),
)
b.WriteString(`<dc:title>` + xmlEsc(it.Title) + `</dc:title>`)
if it.Artist != "" {
b.WriteString(`<upnp:artist>` + xmlEsc(it.Artist) + `</upnp:artist>`)
}
if it.Album != "" {
b.WriteString(`<upnp:album>` + xmlEsc(it.Album) + `</upnp:album>`)
}
b.WriteString(`<upnp:class>` + xmlEsc(it.Class) + `</upnp:class>`)
_, _ = fmt.Fprintf(&b,
`<res size="%d" duration="%s" bitrate="128000" sampleFrequency="8000" nrAudioChannels="1" protocolInfo="http-get:*:%s:*">%s</res>`,
size, dur, xmlEsc(it.MimeType), xmlEsc(resURL),
)
b.WriteString(`</item>`)
writeItemDIDL(&b, it, base)
}
b.WriteString(`</DIDL-Lite>`)
@@ -436,6 +495,43 @@ func (s *Server) browseContainer(c *Container, start, count int, base string) (s
return b.String(), total
}
// browseMetadata returns DIDL-Lite describing a single object (BrowseMetadata),
// which speakers request to resolve a track's <res> URL before playing it.
// Without this, a STORED_MUSIC select of a track ID returns empty metadata and
// the speaker reports INVALID_SOURCE.
func (s *Server) browseMetadata(objectID, base string) (string, int) {
var b strings.Builder
b.WriteString(didlOpen)
switch {
case objectID == "0":
_, _ = fmt.Fprintf(&b,
`<container id="0" parentID="-1" restricted="1" childCount="%d"><dc:title>Root</dc:title><upnp:class>object.container.storageFolder</upnp:class></container>`,
len(s.tree.Containers),
)
case s.tree.containerByID(objectID) != nil:
c := s.tree.containerByID(objectID)
_, _ = fmt.Fprintf(&b,
`<container id=%s parentID=%s restricted="1" childCount="%d">`,
xmlAttr(c.ID), xmlAttr(c.ParentID), len(c.Children),
)
b.WriteString(`<dc:title>` + xmlEsc(c.Title) + `</dc:title>`)
b.WriteString(`<upnp:class>` + xmlEsc(c.Class) + `</upnp:class>`)
b.WriteString(`</container>`)
case s.tree.itemByID(objectID) != nil:
writeItemDIDL(&b, s.tree.itemByID(objectID), base)
default:
b.WriteString(`</DIDL-Lite>`)
return b.String(), 0
}
b.WriteString(`</DIDL-Lite>`)
return b.String(), 1
}
func emptyDIDL() string {
return `<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" ` +
`xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" ` +
@@ -489,9 +585,41 @@ func (s *Server) serveMediaItem(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", item.MimeType)
w.Header().Set("Content-Length", strconv.Itoa(len(item.Payload)))
_, _ = w.Write(item.Payload)
// ServeContent gives us byte-range support, which real speakers use when
// streaming audio (raw io.Writer with a fixed Content-Length does not).
if item.MimeType != "" {
w.Header().Set("Content-Type", item.MimeType)
}
http.ServeContent(w, r, "media."+item.mediaExt(), serveModTime, bytes.NewReader(item.Payload))
}
// ----------------------------------------------------------------------------
// /AlbumArt/<id>.<ext>
// ----------------------------------------------------------------------------
func (s *Server) serveAlbumArt(w http.ResponseWriter, r *http.Request) {
rel := strings.TrimPrefix(r.URL.Path, "/AlbumArt/")
dot := strings.LastIndexByte(rel, '.')
id := rel
if dot >= 0 {
id = rel[:dot]
}
item := s.tree.itemByID(id)
if item == nil || len(item.ArtPayload) == 0 {
http.NotFound(w, r)
return
}
if item.ArtMime != "" {
w.Header().Set("Content-Type", item.ArtMime)
}
http.ServeContent(w, r, "art."+artExt(item.ArtMime), serveModTime, bytes.NewReader(item.ArtPayload))
}
// ----------------------------------------------------------------------------
-26
View File
@@ -86,32 +86,6 @@ func (zr *ZoneRequest) AddMemberByDeviceID(deviceID string) {
zr.Members = append(zr.Members, member)
}
// RemoveMember removes a device from the zone configuration
func (zr *ZoneRequest) RemoveMember(deviceID string) {
for i, member := range zr.Members {
if member.DeviceID == deviceID {
zr.Members = append(zr.Members[:i], zr.Members[i+1:]...)
return
}
}
}
// ClearMembers removes all members from the zone (creates standalone configuration)
func (zr *ZoneRequest) ClearMembers() {
zr.Members = []MemberEntry{}
}
// HasMember checks if a device is in the zone configuration
func (zr *ZoneRequest) HasMember(deviceID string) bool {
for _, member := range zr.Members {
if member.DeviceID == deviceID {
return true
}
}
return false
}
// GetMemberCount returns the number of members in the zone
func (zr *ZoneRequest) GetMemberCount() int {
return len(zr.Members)
-57
View File
@@ -56,63 +56,6 @@ func TestZoneRequest_AddMemberByDeviceID(t *testing.T) {
}
}
func TestZoneRequest_RemoveMember(t *testing.T) {
zr := NewZoneRequest("MASTER123")
zr.AddMember("DEVICE456", "192.0.2.10")
zr.AddMember("DEVICE789", "192.0.2.11")
zr.AddMember("DEVICEABC", "192.0.2.12")
// Remove middle member
zr.RemoveMember("DEVICE789")
if len(zr.Members) != 2 {
t.Errorf("Expected 2 members after removal, got %d", len(zr.Members))
}
// Check that the correct member was removed
for _, member := range zr.Members {
if member.DeviceID == "DEVICE789" {
t.Error("DEVICE789 should have been removed")
}
}
// Remove non-existent member (should not change anything)
zr.RemoveMember("NONEXISTENT")
if len(zr.Members) != 2 {
t.Errorf("Expected 2 members after removing non-existent, got %d", len(zr.Members))
}
}
func TestZoneRequest_ClearMembers(t *testing.T) {
zr := NewZoneRequest("MASTER123")
zr.AddMember("DEVICE456", "192.0.2.10")
zr.AddMember("DEVICE789", "192.0.2.11")
zr.ClearMembers()
if len(zr.Members) != 0 {
t.Errorf("Expected 0 members after clear, got %d", len(zr.Members))
}
}
func TestZoneRequest_HasMember(t *testing.T) {
zr := NewZoneRequest("MASTER123")
zr.AddMember("DEVICE456", "192.0.2.10")
if !zr.HasMember("DEVICE456") {
t.Error("Expected HasMember to return true for DEVICE456")
}
if zr.HasMember("NONEXISTENT") {
t.Error("Expected HasMember to return false for non-existent device")
}
if zr.HasMember("MASTER123") {
t.Error("Expected HasMember to return false for master device")
}
}
func TestZoneRequest_GetMemberCount(t *testing.T) {
zr := NewZoneRequest("MASTER123")
+21
View File
@@ -1436,6 +1436,27 @@ func (ds *DataStore) SaveRecents(account, device string, recents []models.Servic
Recents []RecentXML `xml:"recent"`
}
// Deduplicate by ID before saving; first occurrence wins. A speaker<->marge
// recents sync can otherwise re-store the same recent (same ID) multiple
// times — it then crowds the capped list and evicts other sources from the
// speaker's recents. Mirrors SaveConfiguredSources.
seen := make(map[string]bool)
deduped := make([]models.ServiceRecent, 0, len(recents))
for i := range recents {
if id := recents[i].ID; id != "" {
if seen[id] {
continue
}
seen[id] = true
}
deduped = append(deduped, recents[i])
}
recents = deduped
wrap := RecentsXML{
Recents: make([]RecentXML, 0, len(recents)),
}
@@ -0,0 +1,67 @@
package datastore
import (
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// TestSaveRecents_DeduplicatesByID is a regression test for the recents
// duplication bug: a speaker<->marge sync could re-store the same recent (same
// ID) multiple times, crowding the capped list and evicting other sources from
// the speaker's recents. SaveRecents must dedup by ID (first occurrence wins).
func TestSaveRecents_DeduplicatesByID(t *testing.T) {
tmp, err := os.MkdirTemp("", "recents-dedup-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
ds := NewDataStore(tmp)
account, device := "6919733", "A81B6A536A98"
mk := func(id, name string) models.ServiceRecent {
var r models.ServiceRecent
r.ID = id
r.Name = name
r.Source = "7"
r.SourceAccount = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
r.Location = "1$4$2 TRACK"
return r
}
// Same ID four times (the observed live state), plus two distinct recents.
in := []models.ServiceRecent{
mk("260614006", "03 - Salvation"),
mk("260614006", "03 - Salvation"),
mk("260614006", "03 - Salvation"),
mk("260614006", "03 - Salvation"),
mk("260614004", "06 - Back Burner"),
mk("260613001", "Artifact"),
}
if err := ds.SaveRecents(account, device, in); err != nil {
t.Fatalf("SaveRecents: %v", err)
}
out, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents: %v", err)
}
counts := map[string]int{}
for _, r := range out {
counts[r.ID]++
}
if counts["260614006"] != 1 {
t.Errorf("duplicate recent not deduped: id 260614006 appears %d times (want 1)", counts["260614006"])
}
if len(out) != 3 {
t.Errorf("expected 3 distinct recents, got %d: %+v", len(out), counts)
}
}
+6
View File
@@ -22,6 +22,7 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/export"
"github.com/gesellix/bose-soundtouch/pkg/service/health"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/speaker"
speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh"
"github.com/gesellix/bose-soundtouch/pkg/telnet"
)
@@ -312,6 +313,11 @@ var speakerSSHPaths = []string{
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/pki/tls/certs/ca-bundle.crt.original", // pre-migration CA bundle backup
"/etc/ssl/certs/ca-certificates.crt",
// The speaker's own persisted source list. The firmware registers source
// types from this file at boot; comparing it against the service's
// Sources.xml (and the runtime /sources) is the key evidence when radio
// source types won't activate after an in-place migration.
speaker.SourcesFileLocation,
// Redirection-relevant state: where the speaker resolves Bose hostnames
// and what it had before migration. (The live marge/BMX URL config in
// SoundTouchSdkPrivateCfg.xml is handled by collectSpeakerRedirectConfig,
+12 -7
View File
@@ -3184,6 +3184,7 @@ function setPreflightItemStatus(li, status, message) {
if (status === "running") { icon = "⟳"; color = "#1976d2"; suffix = " — running…"; }
else if (status === "ok") { icon = "✅"; color = "green"; suffix = " — passed"; }
else if (status === "skip") { icon = "—"; color = "#666"; suffix = message ? `${message}` : " — skipped"; }
else if (status === "warn") { icon = "⚠️"; color = "#ed6c02"; suffix = message ? `${message}` : " — warning"; }
else { icon = "❌"; color = "red"; suffix = message ? `${message}` : " — failed"; }
li.innerText = `${icon} ${li.dataset.name}${suffix}`;
li.style.color = color;
@@ -3272,7 +3273,7 @@ async function checkPeerReachability(deviceId) {
}
if (result.error) return {status: "fail", message: result.error.split("\n")[0]};
if (result.result && result.result.reached === false) {
return {status: "fail", message: "no inbound from device before timeout"};
return {status: "warn", message: "no inbound seen within the wait window — usually just timing (the update daemon dials out on its own slow schedule; a reboot after Apply validates it), not a port or config problem. Safe to proceed."};
}
return {status: "fail", message: "probe failed"};
} catch (e) {
@@ -3386,9 +3387,11 @@ function awaitPreflightDecision(results) {
summary.replaceChildren();
if (failed === 0) {
summary.innerText = `${passed} of ${results.length} checks passed` +
(skipped > 0 ? ` (${skipped} skipped)` : "");
summary.style.color = "green";
const warned = results.filter(r => r.status === "warn").length;
let extras = skipped > 0 ? ` (${skipped} skipped)` : "";
if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`;
summary.innerText = `${passed} of ${results.length} checks passed${extras}`;
summary.style.color = warned > 0 ? "#ed6c02" : "green";
actions.replaceChildren();
// Auto-proceed: caller adds a brief delay so the user sees the
// green frame before Apply kicks off.
@@ -3508,9 +3511,11 @@ function renderPreflightPreviewSummary(results) {
if (!summary || !actions) return;
if (failed === 0) {
summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks`
+ (skipped > 0 ? ` (${skipped} skipped)` : "");
summary.style.color = "green";
const warned = results.filter(r => r.status === "warn").length;
let extras = skipped > 0 ? ` (${skipped} skipped)` : "";
if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`;
summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks${extras}`;
summary.style.color = warned > 0 ? "#ed6c02" : "green";
} else {
summary.innerText = `❌ Pre-flight: ${failed} of ${results.length} checks failed`;
summary.style.color = "red";
@@ -100,7 +100,10 @@ func runDNSSpeakerUsageCheck(
"Bose hostnames. It may simply not have played a TuneIn stream since the " +
"last restart, or it may be using a different DNS resolver. " +
"Click 'Test DNS path' to run an active probe that sends a silent " +
"notification and waits for the speaker to call back through AfterTouch's DNS.",
"notification and waits for the speaker to call back through AfterTouch's DNS. " +
"The same DNS path is required for TTS / `/speaker` `play_info` notifications: " +
"the speaker validates the app_key against a hardcoded Bose host, so without " +
"DNS interception those requests time out with ALLEGROWEBSERVER_TIMEOUT (1046).",
QuickFixes: []QuickFix{probeDNSPathQuickFix},
})
}
+1 -1
View File
@@ -155,7 +155,7 @@ func diffSourcesForDeviceWithURL(ds *datastore.DataStore, account, deviceID, ipA
"Speaker is missing %d source type(s) the service advertises: %s.",
len(missingOnSpeaker), strings.Join(missingOnSpeaker, ", "),
),
Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting.",
Details: "After a power-cycle the speaker fetches /full from the service and re-registers its source list. Forcing a sourcesUpdated notification triggers the same refresh without rebooting. If radio source types still won't activate after an in-place migration (a reboot and this notification don't help), see the troubleshooting guide; the confirmed remedy is a factory reset + re-migrate: https://gesellix.github.io/Bose-SoundTouch/docs/guides/TROUBLESHOOTING/#radio-sources-after-migration",
ManualCommands: []ManualCommand{{
Label: "Trigger a sources refresh on the speaker:",
Command: notifyCmd,
@@ -0,0 +1,97 @@
package marge
import (
"os"
"strconv"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestAddSource_MultipleStoredMusicServersCoexist is a regression test for the
// media-server eviction bug: AddSource deduped STORED_MUSIC by provider ID, so
// registering a second DLNA media server overwrote the first. The first then
// vanished from /full + /sources and the speaker dropped it, meaning only one
// media server could ever stay registered.
//
// Each media server is a separate account (username = "<UDN>/0"), so two
// distinct servers must coexist, while re-adding the same server (same account)
// updates in place.
func TestAddSource_MultipleStoredMusicServersCoexist(t *testing.T) {
tempDir, err := os.MkdirTemp("", "addsource-storedmusic-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
ds := datastore.NewDataStore(tempDir)
account := "6919733"
device := "A81B6A536A98"
if mkErr := os.MkdirAll(ds.AccountDeviceDir(account, device), 0o755); mkErr != nil {
t.Fatalf("mkdir device dir: %v", mkErr)
}
sm := strconv.Itoa(constants.StoredMusicProviderID)
const (
fritzAcct = "fa095ecc-e13e-40e7-8e6c-e0286d5bc000/0"
testAcct = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
)
// Register two different media servers.
if _, err := AddSource(ds, account, fritzAcct, sm, "", "", "fritz"); err != nil {
t.Fatalf("add server 1: %v", err)
}
if _, err := AddSource(ds, account, testAcct, sm, "", "", "AfterTouch Test Library"); err != nil {
t.Fatalf("add server 2: %v", err)
}
// storedMusicAccounts returns the set of STORED_MUSIC source accounts the
// datastore would serve via /full + /sources. SourceKey.Account is the
// persisted identity (the display name lives on the speaker, set via
// setMusicServiceAccount, and does not round-trip here).
storedMusicAccounts := func() map[string]bool {
sources, gerr := ds.GetConfiguredSources(account, device)
if gerr != nil {
t.Fatalf("get sources: %v", gerr)
}
out := map[string]bool{}
for _, s := range sources {
if s.SourceProviderID == sm {
out[s.SourceKey.Account] = true
}
}
return out
}
got := storedMusicAccounts()
if len(got) != 2 {
t.Fatalf("expected 2 STORED_MUSIC sources, got %d: %+v", len(got), got)
}
if !got[fritzAcct] {
t.Errorf("first media server was evicted (account %q missing)", fritzAcct)
}
if !got[testAcct] {
t.Errorf("second media server not registered (account %q missing)", testAcct)
}
// Re-adding the SAME server (same account) updates in place; it must not
// create a duplicate or drop the other server.
if _, err := AddSource(ds, account, fritzAcct, sm, "", "", "fritz (renamed)"); err != nil {
t.Fatalf("re-add server 1: %v", err)
}
got = storedMusicAccounts()
if len(got) != 2 || !got[fritzAcct] || !got[testAcct] {
t.Fatalf("re-adding the same server should keep exactly both accounts; got %+v", got)
}
}
+107 -23
View File
@@ -4,6 +4,8 @@ package marge
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/xml"
"fmt"
"log"
@@ -223,6 +225,27 @@ func (p presetParityXML) MarshalXML(e *xml.Encoder, start xml.StartElement) erro
return e.EncodeElement(Alias(p), start)
}
// recentSourceUsername returns the account/username to emit in a recent's
// <source> block. The account (e.g. STORED_MUSIC's "<UDN>/0") is persisted in
// SourceKey.Account, not Username, which does not round-trip through the
// datastore — so fall back to SourceKeyAccount. Without this, STORED_MUSIC
// media-server recents get an empty <username>, the speaker falls back to the
// provider id (e.g. "7") as the account, and replay fails with INVALID_SOURCE.
// Mirrors the fallback in mapToFullResponseSource; TuneIn / Internet Radio /
// Local Internet Radio intentionally keep an empty username (parity).
func recentSourceUsername(src *models.ConfiguredSource) string {
if src.Username != "" {
return src.Username
}
switch src.SourceKeyType {
case constants.ProviderTunein, constants.ProviderInternetRadio, constants.ProviderLocalInternetRadio:
return ""
}
return src.SourceKeyAccount
}
func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentItemParitySource {
sxml := &models.RecentItemParitySource{
ID: src.ID,
@@ -232,7 +255,7 @@ func prepareRecentItemParitySource(src *models.ConfiguredSource) *models.RecentI
Name: src.DisplayName,
SourceProviderID: src.SourceProviderID,
SourceName: src.SourceName,
Username: src.Username,
Username: recentSourceUsername(src),
Credential: &models.RecentItemParityCredential{
Type: src.Credential.Type,
Value: src.Credential.Value,
@@ -593,20 +616,41 @@ func recentToXML(r *models.ServiceRecent, matchingSrc *models.ConfiguredSource)
// ProviderSettingsToXML generates provider settings XML for the specified account.
func ProviderSettingsToXML(account string) string {
return constants.XMLHeader + fmt.Sprintf(`<providerSettings>
<providerSetting>
<boseId>%s</boseId>
<keyName>ELIGIBLE_FOR_TRIAL</keyName>
<value>false</value>
<providerId>14</providerId>
</providerSetting>
<providerSetting>
<boseId>%s</boseId>
<keyName>STREAMING_QUALITY</keyName>
<value>2</value>
<providerId>15</providerId>
</providerSetting>
</providerSettings>`, EscapeXML(account), EscapeXML(account))
type providerSetting struct {
BoseID string `xml:"boseId"`
KeyName string `xml:"keyName"`
Value string `xml:"value"`
ProviderID string `xml:"providerId"`
}
type providerSettings struct {
XMLName xml.Name `xml:"providerSettings"`
Settings []providerSetting `xml:"providerSetting"`
}
payload := providerSettings{
Settings: []providerSetting{
{
BoseID: account,
KeyName: "ELIGIBLE_FOR_TRIAL",
Value: "false",
ProviderID: "14",
},
{
BoseID: account,
KeyName: "STREAMING_QUALITY",
Value: "2",
ProviderID: "15",
},
},
}
out, err := xml.Marshal(payload)
if err != nil {
return constants.XMLHeader + `<providerSettings></providerSettings>`
}
return constants.XMLHeader + string(out)
}
// SoftwareUpdateToXML generates software update configuration XML.
@@ -1996,11 +2040,22 @@ func updateOrCreateRecent(recents []models.ServiceRecent, name string, matchingS
if sourceMatch && r.Location == location {
recents[i].UtcTime = strconv.FormatInt(utcTime, 10)
recents[i].UpdatedOn = FormatTime(time.Now())
recentObj = &recents[i]
// Move to front
recents = append([]models.ServiceRecent{*recentObj}, append(recents[:i], recents[i+1:]...)...)
return recentObj, recents
// Move the matched recent to the front. Copy the value out FIRST,
// then rebuild the slice into a fresh backing array. The previous
// in-place `append(recents[:i], recents[i+1:]...)` shuffle aliased
// the shared backing array and overwrote index i, which both
// corrupted the returned pointer (it pointed at the neighbour) and,
// because Go does not specify evaluation order between `*recentObj`
// and the inner append, could drop the matched recent and duplicate
// its neighbour in the saved list.
matched := recents[i]
reordered := make([]models.ServiceRecent, 0, len(recents))
reordered = append(reordered, matched)
reordered = append(reordered, recents[:i]...)
reordered = append(reordered, recents[i+1:]...)
return &reordered[0], reordered
}
}
@@ -2116,7 +2171,7 @@ func formatRecentResponse(recentObj *models.ServiceRecent, matchingSrc *models.C
Name: matchingSrc.DisplayName,
SourceProviderID: matchingSrc.SourceProviderID,
SourceName: matchingSrc.SourceName,
Username: matchingSrc.Username,
Username: recentSourceUsername(matchingSrc),
}
if res.Source.Name == "TuneIn" || res.Source.Name == "LOCAL_INTERNET_RADIO" {
@@ -2322,11 +2377,27 @@ func RemoveSourceFromAccount(ds *datastore.DataStore, account, sourceID string)
return nil
}
// newSourceID returns a unique opaque source ID. SaveConfiguredSources dedups
// by ID, so it must be collision-free even for sources created in the same
// instant; it uses crypto/rand (64 bits) and falls back to a nanosecond
// timestamp only if the RNG ever fails.
func newSourceID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "SRC_" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
return "SRC_" + hex.EncodeToString(b[:])
}
// AddSource adds a new music source to the account and returns the generated source ID.
func AddSource(ds *datastore.DataStore, account, username, providerID, secret, secretType, sourceName string) (string, error) {
now := time.Now()
createdOn := FormatTime(now)
sourceID := "SRC_" + strconv.FormatInt(now.Unix(), 10)
// SaveConfiguredSources dedups by ID, so IDs must be unique even when two
// sources are added in the same instant. Use a random ID rather than a
// timestamp (which can collide on coarse clocks or rapid calls).
sourceID := newSourceID()
// List accounts directly from the account directory to be sure we find them.
devicesDir := ds.AccountDevicesDir(account)
@@ -2368,11 +2439,24 @@ func AddSource(ds *datastore.DataStore, account, username, providerID, secret, s
PrepareConfiguredSource(&newSrc)
// Update or append. If it's the same provider, we replace it.
// Update or append. Most providers are singletons (one account each), so
// the same provider replaces the existing entry. STORED_MUSIC is the
// exception: each DLNA media server is a separate account (username =
// "<UDN>/0"), so it must only replace when the account also matches.
// Otherwise registering a second media server overwrites the first, which
// then vanishes from /full + /sources and the speaker drops it (only one
// media server could ever stay registered).
replaced := false
for i := range sources {
if sources[i].SourceProviderID == providerID ||
sameProvider := sources[i].SourceProviderID == providerID
if providerID == strconv.Itoa(constants.StoredMusicProviderID) {
// Match on the persisted account identity (SourceKey.Account),
// not Username, which does not round-trip through the datastore.
sameProvider = sameProvider && sources[i].SourceKey.Account == username
}
if sameProvider ||
(providerID == strconv.Itoa(constants.SpotifyProviderID) && sources[i].SourceKey.Type == constants.ProviderSpotify) {
sources[i] = newSrc
replaced = true
@@ -0,0 +1,81 @@
package marge
import (
"testing"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// TestUpdateOrCreateRecent_MoveToFrontPreservesList is a regression test for the
// recents move-to-front corruption: when an existing recent is re-played, the
// in-place slice shuffle at the match branch could (a) return the wrong recent
// (a list neighbor) and (b) drop or duplicate entries in the saved list.
//
// Live evidence (account 6919733 / device A81B6A536A98, 2026-06-14): re-playing
// the Spotify album "White Water" returned the neighboring "Sand Castle Tapes"
// recent, and both Spotify recents subsequently vanished from a list that was
// well under the 10-item cap.
func TestUpdateOrCreateRecent_MoveToFrontPreservesList(t *testing.T) {
spotify := &models.ConfiguredSource{}
spotify.SourceKeyType = "SPOTIFY"
spotify.SourceKeyAccount = "gesellix"
mk := func(id, name, loc string) models.ServiceRecent {
var r models.ServiceRecent
r.ID = id
r.Name = name
r.Source = "SPOTIFY"
r.SourceAccount = "gesellix"
r.Location = loc
return r
}
// Three distinct Spotify recents sharing the same source; they differ only
// by location (the discriminator in the match branch).
recents := []models.ServiceRecent{
mk("1", "Sunday", "loc-sunday"),
mk("2", "White Water", "loc-white"),
mk("3", "Sand Castle", "loc-sand"),
}
// Re-play "White Water" (the middle entry) -> it should move to front,
// the returned recent must BE White Water, and no entry may be lost.
recentObj, out := updateOrCreateRecent(recents, "White Water", spotify, "tracklisturl", "loc-white", "DEVICEID01", 12345)
if recentObj.Name != "White Water" || recentObj.Location != "loc-white" {
t.Errorf("returned recent = %q (loc %q), want White Water/loc-white (neighbor leakage)", recentObj.Name, recentObj.Location)
}
if len(out) != 3 {
t.Fatalf("recents count = %d, want 3 (entry lost/duplicated): %s", len(out), names(out))
}
seen := map[string]int{}
for i := range out {
seen[out[i].Location]++
}
for _, loc := range []string{"loc-sunday", "loc-white", "loc-sand"} {
if seen[loc] != 1 {
t.Errorf("location %q appears %d times, want 1: %s", loc, seen[loc], names(out))
}
}
if out[0].Location != "loc-white" {
t.Errorf("front entry = %q, want White Water moved to front: %s", out[0].Name, names(out))
}
}
func names(rs []models.ServiceRecent) string {
s := "["
for i := range rs {
if i > 0 {
s += ", "
}
s += rs[i].Name + "(" + rs[i].Location + ")"
}
return s + "]"
}
@@ -0,0 +1,89 @@
package marge
import (
"fmt"
"os"
"strconv"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// TestRecent_StoredMusicKeepsAccount is a regression test for the recents-replay
// bug: a STORED_MUSIC media server's account ("<UDN>/0") is persisted in
// SourceKey.Account, not Username (which does not round-trip). The recent
// <source> builders emitted Username verbatim, producing an empty <username>;
// the speaker then fell back to the provider id (e.g. "7") as the account and
// replaying the recent failed with INVALID_SOURCE.
//
// The speaker registers a recent by sourceid only (no account in the POST), so
// the served recent's account must come from the matched source. This asserts
// the served <source> carries the real UDN, and never the bare provider id.
func TestRecent_StoredMusicKeepsAccount(t *testing.T) {
tmp, err := os.MkdirTemp("", "recent-storedmusic-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
ds := datastore.NewDataStore(tmp)
account := "6919733"
device := "A81B6A536A98"
if mkErr := os.MkdirAll(ds.AccountDeviceDir(account, device), 0o755); mkErr != nil {
t.Fatalf("mkdir: %v", mkErr)
}
const udn = "4d696e69-444c-164e-9d41-72ecda78e4c1/0"
sm := strconv.Itoa(constants.StoredMusicProviderID)
srcID, err := AddSource(ds, account, udn, sm, "", "", "AfterTouch Test Library")
if err != nil {
t.Fatalf("add source: %v", err)
}
// The speaker POSTs a recent referencing the source by id only (matches the
// captured Bose_Lisa payload: no <source> account/username).
recXML := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8" ?><recent>`+
`<lastplayedat>2026-06-14T16:35:30+00:00</lastplayedat>`+
`<sourceid>%s</sourceid>`+
`<name>02 - The Raven</name>`+
`<location>1$4$1 TRACK</location>`+
`<contentItemType></contentItemType></recent>`, srcID)
postResp, err := AddRecent(ds, account, device, []byte(recXML))
if err != nil {
t.Fatalf("add recent: %v", err)
}
// 1. The POST response (formatRecentResponse) must carry the real account.
assertAccount(t, "AddRecent response", string(postResp), udn)
// 2. The served recents list (RecentsToXML) must carry it too.
served, err := RecentsToXML(ds, account, device)
if err != nil {
t.Fatalf("RecentsToXML: %v", err)
}
assertAccount(t, "RecentsToXML", string(served), udn)
}
func assertAccount(t *testing.T, what, xml, udn string) {
t.Helper()
if !strings.Contains(xml, "<username>"+udn+"</username>") {
t.Errorf("%s: expected <username>%s</username>; got:\n%s", what, udn, xml)
}
if strings.Contains(xml, "<username></username>") || strings.Contains(xml, "<username/>") {
t.Errorf("%s: STORED_MUSIC source has an empty <username> (account lost):\n%s", what, xml)
}
if strings.Contains(xml, "<username>7</username>") {
t.Errorf("%s: STORED_MUSIC username is the bare provider id, not the account:\n%s", what, xml)
}
}
+82
View File
@@ -36,6 +36,88 @@ func (m *Manager) ResetBoseURLs(deviceIP, serviceURL string) (string, error) {
return m.setBoseURLsViaTelnet(deviceIP, serviceURL, serviceURL+"/update")
}
// EnableSSHViaTelnetFullConfig is the #515 variant of EnableSSHViaTelnet for
// devices where the single-envswitch injection is accepted and persisted but
// sshd never starts (ST Portable, CineMate 520; see also memory note #471). It
// replicates the sequence @Henri-be confirmed by hand over telnet :17000: it
// writes all four `sys configuration` URL keys with the remote_services
// injection on margeServerUrl (the runtime layer, not just the envswitch
// persistence layer), mirrors the injection into `envswitch boseurls set`, and
// verifies with getpdo. The caller should reboot afterwards (the injection
// fires on the speaker's next full config re-parse at boot) and then
// WaitForSSHPort.
//
// serviceURL is the AfterTouch service base the speaker should point at
// (e.g. https://192.0.2.10:8443). It must not contain a double quote.
func (m *Manager) EnableSSHViaTelnetFullConfig(deviceIP, serviceURL string) (string, error) {
u := defaultTelnetURLs(serviceURL)
margeInjected := serviceURL + remoteServicesInjection
// All values are double-quoted: margeInjected contains spaces and
// semicolons, so the device's command parser needs the quotes to keep it
// one argument (the unquoted telnetURLs.Commands() is only safe for clean
// migration URLs).
cmds := []string{
`sys configuration bmxRegistryUrl "` + u.BmxRegistry + `"`,
`sys configuration statsServerUrl "` + u.Stats + `"`,
`sys configuration margeServerUrl "` + margeInjected + `"`,
`sys configuration swUpdateUrl "` + u.SwUpdate + `"`,
`envswitch boseurls set "` + margeInjected + `" "` + u.SwUpdate + `"`,
}
return m.runTelnetInjection(deviceIP, []string{serviceURL, u.SwUpdate}, cmds)
}
// runTelnetInjection opens the port-17000 shell, runs an ordered list of
// commands (aborting on the first transport error or "command not found"
// rejection), then logs a getpdo verification. forbidQuote values are checked
// for an embedded double quote, which would break the command parsing.
// Verification is best-effort (logged, never fatal) to match enable-ssh's
// forgiving philosophy and tolerate the aftertouch.invalid placeholder.
func (m *Manager) runTelnetInjection(deviceIP string, forbidQuote, cmds []string) (string, error) {
if m.NewTelnet == nil {
return "", errors.New("telnet not configured: Manager.NewTelnet is nil")
}
for _, v := range forbidQuote {
if strings.Contains(v, `"`) {
return "", errors.New("boseurls values must not contain a double quote")
}
}
var logs strings.Builder
t := m.NewTelnet(deviceIP)
if err := t.Dial(); err != nil {
return logs.String(), fmt.Errorf("telnet dial %s:17000: %w", deviceIP, err)
}
defer func() { _ = t.Close() }()
if banner, _ := t.Probe(); banner != "" {
fmt.Fprintf(&logs, "Telnet banner: %q\n", strings.TrimSpace(banner))
}
for _, cmd := range cmds {
resp, err := t.SendCommand(cmd)
if err != nil {
return logs.String(), fmt.Errorf("telnet command %q failed: %w", cmd, err)
}
fmt.Fprintf(&logs, "→ %s\n%s\n", cmd, strings.TrimRight(resp, "\r\n"))
if isCommandNotFound(resp) {
return logs.String(), fmt.Errorf("device rejected %q (firmware does not expose this command)", cmd)
}
}
if verify, err := t.SendCommand("getpdo CurrentSystemConfiguration"); err == nil {
fmt.Fprintf(&logs, "→ getpdo CurrentSystemConfiguration\n%s\n", strings.TrimRight(verify, "\r\n"))
}
return logs.String(), nil
}
// setBoseURLsViaTelnet runs `envswitch boseurls set "<marge>" "<swUpdate>"`
// over the port-17000 shell. Both arguments are double-quoted so values
// containing spaces or semicolons (the SSH-enable injection) survive the
+37
View File
@@ -22,6 +22,43 @@ func TestEnableSSHViaTelnet_BuildsInjectedCommand(t *testing.T) {
}
}
func TestEnableSSHViaTelnetFullConfig_BuildsInjectedSequence(t *testing.T) {
const svc = "https://192.0.2.10:8443"
const injected = `https://192.0.2.10:8443;touch /tmp/remote_services;/etc/init.d/sshd start`
want := []string{
`sys configuration bmxRegistryUrl "https://192.0.2.10:8443/bmx/registry/v1/services"`,
`sys configuration statsServerUrl "https://192.0.2.10:8443"`,
`sys configuration margeServerUrl "` + injected + `"`,
`sys configuration swUpdateUrl "https://192.0.2.10:8443/updates/soundtouch"`,
`envswitch boseurls set "` + injected + `" "https://192.0.2.10:8443/updates/soundtouch"`,
`getpdo CurrentSystemConfiguration`,
}
resp := map[string]string{}
for _, c := range want {
resp[c] = "OK\n"
}
f := &fakeTelnet{responses: resp}
m := newFakeTelnetManager(f)
if _, err := m.EnableSSHViaTelnetFullConfig("192.0.2.10", svc); err != nil {
t.Fatalf("EnableSSHViaTelnetFullConfig: %v", err)
}
if len(f.commands) != len(want) {
t.Fatalf("sent %d commands %q\n want %d %q", len(f.commands), f.commands, len(want), want)
}
for i, c := range want {
if f.commands[i] != c {
t.Errorf("command %d = %q\n want %q", i, f.commands[i], c)
}
}
}
func TestResetBoseURLs_BuildsCleanCommand(t *testing.T) {
const svc = "https://192.0.2.10:8443"
+44 -40
View File
@@ -807,58 +807,62 @@ func applyURLOverrides(cfg *PrivateCfg, options map[string]string) {
}
}
// checkCACertTrusted checks if the local CA certificate is already in
// the device's trust store. The CALabel grep works regardless of whether
// Manager.Crypto is configured — only the secondary "match cert payload"
// fallback needs it. CLI callers without Crypto can therefore still
// detect a previously-trusted CA.
// checkCACertTrusted decides whether the device already trusts *this* service's
// CA.
//
// When the service CA is available (Manager.Crypto set), the certificate
// *payload* is authoritative: a previous migration may have left our label
// (CALabel) in the bundle even though the actual CA has since changed — e.g. the
// service CA was regenerated after its data dir was recreated without a
// persistent volume. Matching the label alone would then falsely report the
// device as trusted and skip re-installing the new CA, leaving the speaker
// unable to validate TLS to the service (the symptom seen in #517). So we match
// the current CA's payload and deliberately ignore the (possibly stale) label.
//
// Only when the CA can't be read (e.g. a CLI caller without Crypto) do we fall
// back to the label, which is the best signal available there.
func (m *Manager) checkCACertTrusted(summary *MigrationSummary, deviceIP string) {
client := m.NewSSH(deviceIP)
bundlePath := "/etc/pki/tls/certs/ca-bundle.crt"
// Primary check: our injected label.
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
if err == nil && strings.Contains(output, CALabel) {
summary.CACertTrusted = true
return
}
if m.Crypto != nil {
if certData, ok := m.firstCACertBodyLine(); ok {
// Payload present -> trusted; absent -> not trusted (re-install),
// regardless of a possibly-stale label.
if _, err := client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath)); err == nil {
summary.CACertTrusted = true
}
// Secondary check (only when Manager.Crypto is configured): match
// the actual cert payload — covers older injections that lack the
// label.
if m.Crypto == nil {
return
}
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return
}
// We look for the first part of the certificate (e.g. the first 64 chars of the base64 data)
// to see if it's already in the bundle.
lines := strings.Split(string(caCertPEM), "\n")
var certData string
for _, line := range lines {
if !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") && line != "" {
certData = line
break
return
}
}
if certData == "" {
return
}
// Use grep to check for the certificate data in the bundle
_, err = client.Run(fmt.Sprintf("grep -F %q %s", certData, bundlePath))
if err == nil {
// Fallback for callers without the CA at hand: match our injected label.
output, err := client.Run(fmt.Sprintf("grep -F %q %s", CALabel, bundlePath))
if err == nil && strings.Contains(output, CALabel) {
summary.CACertTrusted = true
}
}
// firstCACertBodyLine returns the first base64 body line of the service CA
// certificate, used as a cheap fingerprint to check whether this exact CA is
// already present in a device's trust bundle. Returns false when the cert can't
// be read or has no body line.
func (m *Manager) firstCACertBodyLine() (string, bool) {
caCertPEM, err := os.ReadFile(m.Crypto.GetCACertPath())
if err != nil {
return "", false
}
for _, line := range strings.Split(string(caCertPEM), "\n") {
if line != "" && !strings.Contains(line, "BEGIN CERTIFICATE") && !strings.Contains(line, "END CERTIFICATE") {
return line, true
}
}
return "", false
}
// MigrateSpeaker configures the speaker at the given IP to use this service.
func (m *Manager) MigrateSpeaker(deviceIP, targetURL, proxyURL string, options map[string]string, method MigrationMethod) (string, error) {
if targetURL == "" {
+61
View File
@@ -453,6 +453,67 @@ func TestCheckCACertTrusted(t *testing.T) {
}
}
// TestCheckCACertTrustedStaleLabel is the #517 regression: a previous
// migration's CALabel survived a service-CA regeneration, so the label is in the
// bundle but the *current* CA payload is not. The device must then be treated as
// NOT trusting the new CA (so the migration re-installs it), instead of being
// falsely skipped on the stale label.
func TestCheckCACertTrustedStaleLabel(t *testing.T) {
tempDir, err := os.MkdirTemp("", "ca-trust-stale")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
cm := certmanager.NewCertificateManager(filepath.Join(tempDir, "certs"))
if err := cm.EnsureCA(); err != nil {
t.Fatalf("Failed to ensure CA: %v", err)
}
m := NewManager("http://localhost:8000", nil, cm)
m.NewSSH = func(_ string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
// Stale label is still present...
if strings.Contains(command, CALabel) {
return CALabel, nil
}
// ...but the current CA payload is not in the bundle.
return "", fmt.Errorf("not found")
},
}
}
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, "192.0.2.10")
if summary.CACertTrusted {
t.Error("stale label without a matching CA payload must not count as trusted (should re-install)")
}
}
// TestCheckCACertTrustedNoCryptoFallback verifies that when the service CA is
// not available (e.g. a CLI caller without Crypto), the injected label remains
// the trust signal.
func TestCheckCACertTrustedNoCryptoFallback(t *testing.T) {
m := NewManager("http://localhost:8000", nil, nil)
m.NewSSH = func(_ string) SSHClient {
return &mockSSH{
runFunc: func(command string) (string, error) {
if strings.Contains(command, CALabel) {
return CALabel, nil
}
return "", fmt.Errorf("not found")
},
}
}
summary := &MigrationSummary{}
m.checkCACertTrusted(summary, "192.0.2.10")
if !summary.CACertTrusted {
t.Error("without Crypto, a present label should count as trusted")
}
}
func TestTestConnection(t *testing.T) {
tempDir, err := os.MkdirTemp("", "test-connection")
if err != nil {
+41 -21
View File
@@ -998,22 +998,22 @@ func (app *WebApp) HandleZoneRemove(w http.ResponseWriter, r *http.Request) {
return
}
if masterConn.Client == nil || slaveConn.DeviceInfo == nil {
if masterConn.Client == nil || masterConn.DeviceInfo == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
zoneReq := zone.ToZoneRequest()
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
masterHwID := masterConn.DeviceInfo.DeviceID
slaveHwID := slaveConn.DeviceInfo.DeviceID
// Remove a single member with the dedicated /removeZoneSlave endpoint.
// Rebuilding the zone via /setZone with the remaining members does not
// reliably drop a member when the zone has more than one: the speaker only
// goes standalone when the resulting member set is empty, so removing one of
// several members appeared to do nothing (#511). /removeZoneSlave targets the
// specific member.
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device removed from zone")
app.sendControlResponse(w, masterConn.Client.RemoveZoneSlave(masterHwID, slaveHwID, slaveIP), "Device removed from zone")
}
// HandleZoneDissolve dissolves the zone, making all devices standalone.
@@ -1073,17 +1073,15 @@ func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
return
}
masterZone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
zoneReq := masterZone.ToZoneRequest()
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
// Drop this slave with the dedicated /removeZoneSlave endpoint sent to the
// master. Rebuilding the zone via /setZone with the remaining members does
// not drop a member from a multi-member zone (the master only goes standalone
// when the resulting set is empty), so leaving a 3+ device zone did nothing
// (#511). zone.Master is the master's hwID.
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Left zone")
app.sendControlResponse(w,
masterConn.Client.RemoveZoneSlave(zone.Master, slaveConn.DeviceInfo.DeviceID, slaveIP),
"Left zone")
}
// HandleDeviceRecents returns recently played items for a device.
@@ -1114,6 +1112,19 @@ func (app *WebApp) HandleDeviceRecents(w http.ResponseWriter, r *http.Request) {
}
}
// storedMusicTypeForReplay derives a STORED_MUSIC ContentItem type from the
// speaker-native location, which ends with the item kind (e.g. "1$4$2 TRACK"
// or a container's "… DIR"). Recents don't store the type, and the speaker
// rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. Falls back to
// "track" when the location has no kind suffix.
func storedMusicTypeForReplay(location string) string {
if fields := strings.Fields(location); len(fields) >= 2 {
return strings.ToLower(fields[len(fields)-1])
}
return "track"
}
// HandleDevicePlay plays an arbitrary content item on a device. Generic
// counterpart to HandlePlayTuneIn — used by the Recents panel to replay
// items the speaker reports under /recents, regardless of their source.
@@ -1151,9 +1162,18 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
return
}
// Recents don't carry a contentItemType for STORED_MUSIC, and the speaker
// rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. The
// speaker-native location ends with the item kind (e.g. "1$4$2 TRACK"), so
// derive the type from it when the caller didn't supply one.
ciType := req.Type
if ciType == "" && req.Source == "STORED_MUSIC" {
ciType = storedMusicTypeForReplay(req.Location)
}
contentItem := &models.ContentItem{
Source: req.Source,
Type: req.Type,
Type: ciType,
Location: req.Location,
ItemName: req.ItemName,
ContainerArt: req.ContainerArt,
+29 -1
View File
@@ -143,6 +143,12 @@ func (app *WebApp) HandleDeviceLibraryServers(w http.ResponseWriter, r *http.Req
// contain {udn, name}. The account sent to the speaker is "<udn>/0" as
// required by the STORED_MUSIC protocol. Error code 1024 from the speaker
// means the account is already registered and is treated as success.
//
// After a successful registration the handler fires a best-effort
// sourcesUpdated notification so the speaker re-fetches its account list and
// registers the new source without requiring a power-cycle. The notification
// outcome is reflected in the response field "refreshed" but never fails the
// request.
func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
@@ -183,11 +189,33 @@ func (app *WebApp) HandleAddLibraryServer(w http.ResponseWriter, r *http.Request
}
}
// Resolve the Bose device ID for the sourcesUpdated nudge. Prefer the
// cached DeviceInfo (no extra round-trip); fall back to a live /info
// fetch only if the cached value is absent or empty.
boseDeviceID := ""
if device.DeviceInfo != nil && device.DeviceInfo.DeviceID != "" {
boseDeviceID = device.DeviceInfo.DeviceID
} else {
if info, infoErr := device.Client.GetDeviceInfo(); infoErr == nil && info != nil {
boseDeviceID = info.DeviceID
}
}
// Send the sourcesUpdated nudge best-effort: the registration already
// succeeded, so an error here must never fail the request.
refreshed := false
if boseDeviceID != "" {
if nudgeErr := device.Client.NotifySourcesUpdated(boseDeviceID); nudgeErr == nil {
refreshed = true
}
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]string{"account": account},
Data: map[string]interface{}{"account": account, "refreshed": refreshed},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
@@ -73,12 +73,14 @@ func setupSpeakerMock(t *testing.T, responseMap map[string]string) (*httptest.Se
}
// newLibraryTestApp builds a WebApp with a single device whose Client points
// at the given speaker URL. The device is registered under "lib-device".
// at the given speaker URL. The device is registered under "lib-device" with
// a non-empty DeviceID so HandleAddLibraryServer can resolve the Bose ID from
// the cached DeviceInfo without a /info fallback.
func newLibraryTestApp(speakerURL string) *WebApp {
app := NewWebApp()
c := client.NewClient(&client.Config{Host: speakerURL})
info := &models.DeviceInfo{Name: "Library Test Speaker"}
info := &models.DeviceInfo{Name: "Library Test Speaker", DeviceID: "AABBCCDDEEFF"}
conn := webtypes.NewDeviceConnection(c, info)
conn.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("lib-device", conn)
@@ -511,6 +513,8 @@ func TestNormalizeUDN(t *testing.T) {
// TestHandleAddLibraryServer_AccountFormat verifies that the speaker receives
// a setMusicServiceAccount call with the account set to "<bare-uuid>/0", i.e.
// any "uuid:" prefix is stripped before the "/0" suffix is appended.
// It also asserts that a POST /notification (sourcesUpdated nudge) is sent
// after a successful registration and that the response carries refreshed=true.
func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
tests := []struct {
name string
@@ -532,8 +536,10 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// The client parses the response XML and checks for the success sentinel.
// Also handle /notification so NotifySourcesUpdated succeeds.
speaker, captured := setupSpeakerMock(t, map[string]string{
"/setMusicServiceAccount": `<status>/setMusicServiceAccount</status>`,
"/notification": `<status>/notification</status>`,
})
defer speaker.Close()
@@ -572,7 +578,17 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
t.Errorf("setMusicServiceAccount XML should contain %q, got:\n%s", tt.wantAccount, setXML)
}
// The response account field must also be the bare form.
// A sourcesUpdated nudge must have been POSTed to /notification.
notifXML := captured["/notification"]
if notifXML == "" {
t.Fatal("speaker /notification was never called (sourcesUpdated nudge missing)")
}
if !strings.Contains(notifXML, "sourcesUpdated") {
t.Errorf("/notification body should contain 'sourcesUpdated', got:\n%s", notifXML)
}
// The response must carry the account and refreshed=true.
data, ok := resp.Data.(map[string]interface{})
if !ok {
t.Fatalf("resp.Data is not a map: %T", resp.Data)
@@ -581,10 +597,71 @@ func TestHandleAddLibraryServer_AccountFormat(t *testing.T) {
if got, _ := data["account"].(string); got != tt.wantAccount {
t.Errorf("response account = %q, want %q", got, tt.wantAccount)
}
if refreshed, _ := data["refreshed"].(bool); !refreshed {
t.Errorf("response refreshed should be true, got %v", data["refreshed"])
}
})
}
}
// TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered verifies that
// the sourcesUpdated nudge is also fired for the 1024 (already-registered)
// idempotent path, since the source still needs to be re-registered on the
// speaker.
func TestHandleAddLibraryServer_NudgeSentAfterAlreadyRegistered(t *testing.T) {
alreadyRegistered := `<errors deviceID="AABBCCDDEEFF">
<error value="1024">1024: Account already exists</error>
</errors>`
var notifCalled int
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/setMusicServiceAccount":
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(alreadyRegistered))
case "/notification":
notifCalled++
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<status>/notification</status>`))
default:
w.WriteHeader(http.StatusOK)
}
}))
defer speaker.Close()
app := newLibraryTestApp(speaker.URL)
body := strings.NewReader(`{"udn":"uuid:nas-udn","name":"My NAS"}`)
req := httptest.NewRequest("POST",
"/api/control/devices/lib-device/library/servers",
body)
req.Header.Set("Content-Type", "application/json")
req = withChiParams(req, map[string]string{"id": "lib-device"})
w := httptest.NewRecorder()
app.HandleAddLibraryServer(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp webtypes.APIResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success {
t.Errorf("expected success=true when error contains 1024, got error=%s", resp.Error)
}
if notifCalled == 0 {
t.Error("expected /notification to be called for already-registered path, but it was not")
}
}
// TestHandleAddLibraryServer_MissingUDN checks that omitting udn returns 400.
func TestHandleAddLibraryServer_MissingUDN(t *testing.T) {
speaker, _ := setupSpeakerMock(t, nil)
+124
View File
@@ -769,3 +769,127 @@ func TestHandleSourceControl_ForwardsAccount(t *testing.T) {
})
}
}
// TestHandleZoneRemove_UsesRemoveZoneSlave is the #511 regression: removing one
// member from a multi-member zone must target that member via /removeZoneSlave.
// The previous implementation rebuilt the zone with /setZone and the remaining
// members, which the speaker only honoured when the resulting member set was
// empty — so removing one of several members appeared to do nothing, while
// removing the last member (empty set == dissolve) worked.
func TestHandleZoneRemove_UsesRemoveZoneSlave(t *testing.T) {
var gotPath, gotBody string
speaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer speaker.Close()
app := NewWebApp()
// Registry is IP-keyed; use RFC-5737 documentation addresses.
master := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: speaker.URL}),
&models.DeviceInfo{Name: "Master", DeviceID: "MASTERHW01"},
)
master.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("192.0.2.10", master)
slave := webtypes.NewDeviceConnection(nil, &models.DeviceInfo{Name: "Slave", DeviceID: "SLAVEHW02"})
app.AddDevice("192.0.2.20", slave)
req := httptest.NewRequest("POST", "/api/control/devices/192.0.2.10/zone/remove/192.0.2.20", nil)
req = withChiParams(req, map[string]string{"id": "192.0.2.10", "slaveId": "192.0.2.20"})
w := httptest.NewRecorder()
app.HandleZoneRemove(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if gotPath != "/removeZoneSlave" {
t.Errorf("expected POST to /removeZoneSlave, got %q (a /setZone rebuild does not drop a member from a multi-member zone)", gotPath)
}
if !strings.Contains(gotBody, "SLAVEHW02") {
t.Errorf("removeZoneSlave body should target the slave device ID, got: %s", gotBody)
}
if !strings.Contains(gotBody, `master="MASTERHW01"`) {
t.Errorf("removeZoneSlave body should name the master, got: %s", gotBody)
}
}
// TestHandleZoneLeave_UsesRemoveZoneSlave is the #511 regression for the slave's
// "Leave zone" path: it must drop the slave via /removeZoneSlave on the master,
// not rebuild the master's zone with /setZone (which leaves a 3+ device zone
// unchanged). The leaving slave's "id" is its IP; it carries the master's hwID
// in its /getZone, which we resolve to the master's registry entry.
func TestHandleZoneLeave_UsesRemoveZoneSlave(t *testing.T) {
var masterPath, masterBody string
// Master speaker captures the /removeZoneSlave call.
masterSpeaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
masterPath = r.URL.Path
b, _ := io.ReadAll(r.Body)
masterBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer masterSpeaker.Close()
// Slave speaker answers /getZone naming the master by hwID.
slaveSpeaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/getZone" {
w.Header().Set("Content-Type", "application/xml")
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8" ?>
<zone master="MASTERHW01">
<member ipaddress="192.0.2.20">SLAVEHW02</member>
<member ipaddress="192.0.2.30">SLAVEHW03</member>
</zone>`))
return
}
w.WriteHeader(http.StatusOK)
}))
defer slaveSpeaker.Close()
app := NewWebApp()
master := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: masterSpeaker.URL}),
&models.DeviceInfo{Name: "Master", DeviceID: "MASTERHW01"},
)
master.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("192.0.2.10", master)
slave := webtypes.NewDeviceConnection(
client.NewClient(&client.Config{Host: slaveSpeaker.URL}),
&models.DeviceInfo{Name: "Slave", DeviceID: "SLAVEHW02"},
)
slave.SetStatus(&webtypes.DeviceStatus{IsConnected: true, LastActivity: time.Now()})
app.AddDevice("192.0.2.20", slave)
req := httptest.NewRequest("POST", "/api/control/devices/192.0.2.20/zone/leave", nil)
req = withChiParams(req, map[string]string{"id": "192.0.2.20"})
w := httptest.NewRecorder()
app.HandleZoneLeave(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if masterPath != "/removeZoneSlave" {
t.Errorf("expected POST to master's /removeZoneSlave, got %q", masterPath)
}
if !strings.Contains(masterBody, "SLAVEHW02") {
t.Errorf("removeZoneSlave body should target the leaving slave, got: %s", masterBody)
}
if !strings.Contains(masterBody, `master="MASTERHW01"`) {
t.Errorf("removeZoneSlave body should name the master, got: %s", masterBody)
}
}
@@ -146,6 +146,37 @@ img { display: block; max-width: 100%; }
font-family: monospace;
}
/* Small screens (phones, portrait): the absolutely-centered page title and the
right-aligned icon bar share the same space in the fixed-height navbar and
collide (#500). Let the navbar wrap into two rows: row 1 keeps the logo with
the title beside it (the title fills the remaining width and ellipsizes), and
the icon bar drops onto its own full-width row below. */
@media (max-width: 600px) {
.navbar {
flex-wrap: wrap;
height: auto;
min-height: 52px;
row-gap: 0.25rem;
padding-top: 0.4rem;
padding-bottom: 0.4rem;
}
.page-title {
position: static;
transform: none;
flex: 1 1 auto; /* sit next to the logo and fill the rest of row 1 */
width: auto;
min-width: 0; /* allow the title to shrink + ellipsize */
}
.nav-links {
order: 1; /* force the icon bar onto its own row below */
width: 100%;
justify-content: center;
flex-wrap: wrap;
}
}
.nav-logo {
width: 24px;
height: 24px;
@@ -96,10 +96,13 @@ export function Library({ devices }) {
}
async function playEntry(entry) {
// Pass the entry's own type so a folder selects as a container ("dir")
// rather than a single track — that lets the speaker queue the folder so
// next/previous and auto-advance work, instead of stopping after one item.
await api.libraryPlay(deviceId, {
account: server.account,
location: entry.location,
type: 'track',
type: entry.type || 'track',
name: entry.name,
});
setPlayingName(entry.name);
@@ -251,10 +254,10 @@ export function Library({ devices }) {
<span class="tunein-item-name">${entry.name}</span>
${entry.type ? html`<span class="tunein-item-desc">${entry.type}</span>` : null}
</div>
${entry.playable && !entry.isDir ? html`
${entry.playable || entry.isDir ? html`
<button
class="tunein-play-btn"
title="Play on ${devices[deviceId]?.info?.name || deviceId}"
title="${entry.isDir ? 'Play folder' : 'Play'} on ${devices[deviceId]?.info?.name || deviceId}"
onClick=${(e) => { e.stopPropagation(); playEntry(entry); }}
></button>
` : null}
@@ -52,7 +52,7 @@ export function Zone({ deviceId, devices }) {
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 deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
const deviceName = (ip) => devices[ip]?.info?.name || ip;
return html`
<div class="zone-section">
@@ -76,7 +76,7 @@ export function Zone({ deviceId, devices }) {
${(zone.members || []).map(m => html`
<div class="zone-member" key=${m.ip}>
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">${m.name || m.ip}</span>
<span class="zone-member-name">${m.name || deviceName(m.ip)}</span>
<button class="btn-icon zone-remove" title="Remove from zone"
onClick=${() => removeDevice(m.ip)}></button>
</div>
@@ -93,7 +93,7 @@ export function Zone({ deviceId, devices }) {
${zone.isSlave && html`
<div class="zone-row">
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
<span class="zone-member-name">Zone: ${zone.masterName || deviceName(zone.masterIp)}</span>
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
</div>
`}
@@ -105,7 +105,10 @@ export function Zone({ deviceId, devices }) {
<div class="picker-devices">
${available.map(([ip, d]) => html`
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
${d.info?.Name ?? ip}
<div class="picker-device-info">
<span class="picker-device-name">${d.info?.name || ip}</span>
<span class="picker-device-ip">${d.info?.ip_address || ip}</span>
</div>
</button>
`)}
</div>
@@ -0,0 +1,26 @@
package soundtouchweb
import "testing"
// TestStoredMusicTypeForReplay covers deriving the ContentItem type from a
// STORED_MUSIC recent's location when the stored type is empty. Without a type
// the speaker rejects the select with INVALID_SOURCE.
func TestStoredMusicTypeForReplay(t *testing.T) {
cases := []struct {
location string
want string
}{
{"1$4$2 TRACK", "track"},
{"5:audio5:part13:5521:5 TRACK", "track"},
{"1 DIR", "dir"},
{"1 CONTAINER", "container"},
{"noSuffix", "track"}, // fallback
{"", "track"}, // fallback
}
for _, c := range cases {
if got := storedMusicTypeForReplay(c.location); got != c.want {
t.Errorf("storedMusicTypeForReplay(%q) = %q, want %q", c.location, got, c.want)
}
}
}
+26 -32
View File
@@ -12,6 +12,7 @@ package fakespeaker
import (
"context"
"embed"
"encoding/xml"
"errors"
"fmt"
"io"
@@ -19,6 +20,8 @@ import (
"net/http"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
//go:embed testdata/info.xml testdata/presets.xml testdata/recents.xml testdata/networkinfo.xml testdata/sources.xml testdata/supportedurls.xml testdata/now_playing.xml
@@ -319,46 +322,37 @@ func handleRemoveGroup(w http.ResponseWriter, r *http.Request) {
serveEmptyGroup(w, r)
}
// buildAddGroupResponse inserts <status>GROUP_OK</status> before the
// closing </group> tag of the posted body. If the body is empty or does
// not contain </group>, it falls back to a minimal canned success
// response so callers still see a 200 + parseable XML.
// buildAddGroupResponse echoes the posted group payload back with a
// <status>GROUP_OK</status> appended, mimicking a real speaker's success
// response. The body is parsed into typed fields and re-marshalled (rather
// than splicing raw bytes) so every echoed value is XML-escaped. If the body
// is empty or unparseable, it falls back to a minimal canned success response
// so callers still see a 200 + parseable XML.
func buildAddGroupResponse(posted []byte) []byte {
const closeTag = "</group>"
const okFragment = " <status>GROUP_OK</status>\n"
cannedOK := []byte(`<?xml version="1.0" encoding="UTF-8"?>
<group>
<status>GROUP_OK</status>
</group>
`)
if len(posted) == 0 {
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
return cannedOK
}
idx := indexOfClose(posted, closeTag)
if idx < 0 {
return []byte(`<?xml version="1.0" encoding="UTF-8"?>` + "\n<group>\n" + okFragment + closeTag + "\n")
// Parse into the canonical request type and re-marshal it (rather than
// splicing the raw bytes) so every echoed value is XML-escaped and the
// fake stays in lock-step with whatever fields the client actually sends.
var in models.Group
if err := xml.Unmarshal(posted, &in); err != nil {
return cannedOK
}
out := make([]byte, 0, len(posted)+len(okFragment))
out = append(out, posted[:idx]...)
out = append(out, []byte(okFragment)...)
out = append(out, posted[idx:]...)
in.Status = "GROUP_OK"
return out
}
// indexOfClose returns the index of the last occurrence of needle in b,
// or -1 if not present. We scan from the right because real-world
// payloads can technically nest <group> blocks (e.g. inside <roles>),
// even though the documented stereo-pair payload does not.
func indexOfClose(b []byte, needle string) int {
if len(needle) == 0 || len(b) < len(needle) {
return -1
}
for i := len(b) - len(needle); i >= 0; i-- {
if string(b[i:i+len(needle)]) == needle {
return i
}
data, err := xml.Marshal(in)
if err != nil {
return cannedOK
}
return -1
return append([]byte(`<?xml version="1.0" encoding="UTF-8"?>`+"\n"), data...)
}
+12 -4
View File
@@ -48,6 +48,10 @@ Then, run the following command to install AfterTouch on the device.
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
```
This installs the **latest release** by default (the script resolves it from
GitHub's `releases/latest` redirect). To pin a specific version, see
[Updating AfterTouch](#updating-aftertouch) below.
After the installation check if you can access AfterTouch from your local device by navigating to `http://<IP_ADDRESS_OF_SPEAKER>:8000`. If you can access the AfterTouch UI, you're good to go!
### If `http://<IP_ADDRESS_OF_SPEAKER>:8000` fails: SSH port forwarding
@@ -91,17 +95,21 @@ Run the installer again with the version you want to install. The script backs u
```bash
# 1. Environment variable (works when piping into sh)
VERSION=0.107.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
VERSION=0.111.3 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# 2. Command-line flag (pass args after `sh -s --`)
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.111.3
# 3. Download first, then run with a flag
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
sh install.sh --version 0.107.0
sh install.sh --version 0.111.3
```
Running **without** a version override installs the version hard-coded in the script (the latest release at the time the script was published). That default is updated with each release; if you're running from `main`, it reflects the most recent tagged version.
Running **without** a version override installs the latest release: the script
follows GitHub's `https://github.com/gesellix/Bose-SoundTouch/releases/latest`
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:
> ```bash
+32 -9
View File
@@ -1,19 +1,18 @@
#!/bin/bash
set -eo pipefail
# Default version installed when no override is provided. Update this value
# each time a new release is cut so that running the canonical one-liner
# Version to install. Left empty by default so the canonical one-liner
# curl -sSL .../install.sh | sh
# picks up the latest binary without extra arguments.
# resolves and installs the latest release automatically (see below).
#
# Override via environment variable or the --version/-v flag:
# VERSION=0.107.0 curl -sSL .../install.sh | sh
# curl -sSL .../install.sh | sh -s -- --version 0.107.0
VERSION=${VERSION:-0.107.0}
# Pin a specific version via environment variable or the --version/-v flag:
# VERSION=0.111.3 curl -sSL .../install.sh | sh
# curl -sSL .../install.sh | sh -s -- --version 0.111.3
VERSION=${VERSION:-}
# Parse optional command-line arguments so the script can be invoked as:
# install.sh --version 0.107.0
# install.sh -v 0.107.0
# install.sh --version 0.111.3
# install.sh -v 0.111.3
while [ $# -gt 0 ]; do
case "$1" in
--version|-v)
@@ -27,6 +26,30 @@ while [ $# -gt 0 ]; do
done
GH_REPO=${GH_REPO:-gesellix/Bose-SoundTouch}
# Used only when the latest-release lookup fails (offline / rate-limited /
# a curl without -w support).
FALLBACK_VERSION=${FALLBACK_VERSION:-0.111.3}
# Resolve the latest release when no explicit version was provided, by
# following the stable redirect https://github.com/<repo>/releases/latest
# -> .../releases/tag/vX.Y.Z and taking the tag from the final URL. The
# leading "v" is stripped because the URLs below add it back (v$VERSION).
if [ -z "$VERSION" ]; then
LATEST_URL="https://github.com/$GH_REPO/releases/latest"
echo "Resolving latest release via $LATEST_URL ..."
EFFECTIVE=$(curl -sSLI -o /dev/null -w '%{url_effective}' "$LATEST_URL" 2>/dev/null) || true
TAG=${EFFECTIVE##*/}
VER=${TAG#v}
case "$VER" in
[0-9]*.[0-9]*) VERSION="$VER" ;;
*)
VERSION="$FALLBACK_VERSION"
echo "WARNING: could not resolve latest release; using fallback $VERSION" >&2
;;
esac
fi
BINARY_URL=${BINARY_URL:-https://github.com/$GH_REPO/releases/download/v$VERSION/soundtouch-service-v$VERSION-linux-armv7}
INIT_SCRIPT_URL=${INIT_SCRIPT_URL:-https://raw.githubusercontent.com/$GH_REPO/v$VERSION/scripts/on-device-install/aftertouch}
+35 -6
View File
@@ -21,14 +21,43 @@ sudo bash install.sh
**soundtouch-player** (browser control panel):
```bash
curl -fsSL -o install-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
sudo bash install-web.sh
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
```
Pass a version tag as the first argument to pin a specific release:
Both install the **latest release** by default (resolved from GitHub's
`releases/latest` redirect). Pass a version tag as the first argument to pin a
specific release:
```bash
sudo bash install.sh v0.107.0
sudo bash install-web.sh v0.107.0
sudo bash install.sh v0.111.3
sudo bash install-player.sh v0.111.3
```
## Removal
Matching uninstallers reverse each installer (stop and disable the service,
remove the unit, binary, and config). You can also remove things by hand — see
the [full guide](../../docs/content/docs/guides/RASPBERRY-PI.md) for the manual
commands.
```bash
# soundtouch-service (keeps the data directory unless you pass --purge):
curl -fsSL -o uninstall.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall.sh
sudo bash uninstall.sh
# soundtouch-player:
curl -fsSL -o uninstall-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-player.sh
sudo bash uninstall-player.sh
# soundtouch-web (deprecated; switch to soundtouch-player afterwards):
curl -fsSL -o uninstall-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/uninstall-web.sh
sudo bash uninstall-web.sh
```
The shared `soundtouch:soundtouch` user/group is removed only once no other
`soundtouch-*` install remains on the host.
+45 -5
View File
@@ -10,7 +10,7 @@ set -euo pipefail
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.107.0 \
# VERSION=v0.111.3 \
# HTTP_PORT=8081 \
# bash install-player.sh
#
@@ -21,7 +21,7 @@ set -euo pipefail
# bash install-player.sh
#
# Or with a version argument to perform an update:
# sudo bash install-player.sh v0.107.0
# sudo bash install-player.sh v0.111.3
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
@@ -32,11 +32,16 @@ set -euo pipefail
# - Safe to re-run; it will update the binary, env file, and unit and restart.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.107.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
# Release to install. Empty means "resolve the latest release" (see
# resolve_version). Pass a tag/number as $1 or VERSION=... to pin a release.
VERSION="${1:-${VERSION:-}}"
# Normalize version prefix for an explicitly provided version.
if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
# Used only when the latest-release lookup fails (offline / rate-limited).
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
@@ -157,6 +162,40 @@ download_binary() {
log "Installed binary to ${BIN_PATH}"
}
resolve_version() {
# When no explicit version was given, resolve the latest release tag by
# following the documented stable redirect:
# https://github.com/<owner>/<repo>/releases/latest
# which 302-redirects to .../releases/tag/vX.Y.Z. We read the final URL and
# take the tag from it. Falls back to FALLBACK_VERSION on any failure
# (offline, rate-limited, no usable curl/wget).
if [[ -n "$VERSION" ]]; then
return
fi
local latest_url="https://github.com/${GH_REPO}/releases/latest"
log "Resolving latest release via ${latest_url}"
local effective="" tag=""
if command -v curl >/dev/null 2>&1; then
effective="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null)" || true
else
# wget: don't follow the redirect, read the Location header instead.
effective="$(wget -S --max-redirect=0 -O /dev/null "$latest_url" 2>&1 \
| awk 'tolower($1) ~ /location:/ {print $2}' | tr -d '\r' | tail -1)" || true
fi
tag="${effective##*/}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+ ]]; then
[[ "$tag" =~ ^v ]] || tag="v${tag}"
VERSION="$tag"
log "Latest release is ${VERSION}"
else
VERSION="$FALLBACK_VERSION"
log "⚠️ Could not resolve latest release; falling back to ${VERSION}"
fi
}
self_update() {
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
return
@@ -324,6 +363,7 @@ main() {
apt_install_if_missing curl
fi
resolve_version
self_update "$@"
ensure_user_group
-349
View File
@@ -1,349 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# DEPRECATED: soundtouch-web has been renamed to soundtouch-player.
# This installer still works (the soundtouch-web release asset is published as
# a transitional alias) but will be removed in a future release. Please switch
# to install-player.sh, which installs the soundtouch-player binary/service.
# ==============================================================================
echo "NOTICE: soundtouch-web has been renamed to soundtouch-player." >&2
echo " This installer (install-web.sh) is deprecated and will be removed" >&2
echo " in a future release; please switch to install-player.sh." >&2
echo >&2
# ==============================================================================
# Bose-SoundTouch soundtouch-web installer (systemd, headless)
#
# Usage:
# sudo bash install-web.sh [vX.Y.Z]
#
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.107.0 \
# HTTP_PORT=8081 \
# bash install-web.sh
#
# # With an AfterTouch service link for TTS (HTTPS + self-signed CA):
# sudo \
# SERVICE_URL=https://soundtouch.local \
# SERVICE_CA=/var/lib/soundtouch-service/certs/ca.crt \
# bash install-web.sh
#
# Or with a version argument to perform an update:
# sudo bash install-web.sh v0.107.0
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
# - soundtouch-web is stateless (no data directory) — it is safe to stop/restart freely.
# - Default port is 8080 (unprivileged — no special capabilities needed).
# - If soundtouch-service is already installed, soundtouch-web reuses the
# existing soundtouch:soundtouch user/group.
# - Safe to re-run; it will update the binary, env file, and unit and restart.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.107.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
SERVICE_NAME="${SERVICE_NAME:-soundtouch-web}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-web}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-web}"
ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-web.env}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
# Port (unprivileged — no CAP_NET_BIND_SERVICE needed)
HTTP_PORT="${HTTP_PORT:-8080}"
# Optional discovery / device config
BIND_ADDR="${BIND_ADDR:-}"
DISCOVERY_INTERFACE="${DISCOVERY_INTERFACE:-}"
SOUNDTOUCH_DEVICES="${SOUNDTOUCH_DEVICES:-}"
# Optional AfterTouch service link (needed for TTS / "Speak").
# SERVICE_URL: base URL of soundtouch-service, e.g. https://soundtouch.local
# SERVICE_CA: path to the service CA cert when it serves HTTPS with its own
# self-signed certificate, e.g. /var/lib/soundtouch-service/certs/ca.crt
SERVICE_URL="${SERVICE_URL:-}"
SERVICE_CA="${SERVICE_CA:-}"
# Override if you want to force a specific asset suffix:
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
ARCH_ASSET="${ARCH_ASSET:-}"
# Internal variables
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")"
IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
apt_install_if_missing() {
log "Installing dependencies: $*"
apt-get update -y
apt-get install -y --no-install-recommends "$@"
}
detect_arch_asset() {
local m
m="$(uname -m)"
case "$m" in
armv7l|armv6l)
echo "linux-armv7"
;;
aarch64)
echo "linux-arm64"
;;
x86_64|amd64)
echo "linux-amd64"
;;
*)
die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)"
;;
esac
}
download_url_for() {
local asset="$1"
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-web-${VERSION}-${asset}"
}
ensure_user_group() {
log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}"
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
groupadd --system "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
useradd --system \
--no-create-home \
--shell /usr/sbin/nologin \
--gid "${SERVICE_GROUP}" \
"${SERVICE_USER}"
fi
}
ensure_dirs() {
log "Creating config directory"
mkdir -p "${CONFIG_DIR}"
chmod 0755 "${CONFIG_DIR}"
}
download_binary() {
local asset url tmp=""
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
url="$(download_url_for "$asset")"
log "Downloading binary for ${asset}: ${url}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "${tmp}/soundtouch-web" "${url}"
else
wget -qO "${tmp}/soundtouch-web" "${url}"
fi
chmod +x "${tmp}/soundtouch-web"
if [[ -f "${BIN_PATH}" ]]; then
log "Backing up existing binary to ${BIN_PATH}.old"
cp -p "${BIN_PATH}" "${BIN_PATH}.old"
fi
install -m 0755 "${tmp}/soundtouch-web" "${BIN_PATH}"
log "Installed binary to ${BIN_PATH}"
}
self_update() {
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
return
fi
local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install-web.sh"
local tmp_script="/tmp/soundtouch-web-install-${VERSION}.sh"
log "Checking for installer updates for ${VERSION}..."
log "URL: ${url}"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL -o "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
else
if ! wget -qO "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
fi
if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then
log "Installer is already up to date."
rm -f "${tmp_script}"
return
fi
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
rm -f "${tmp_script}"
export IS_SELF_UPDATE="true"
export VERSION HTTP_PORT BIND_ADDR DISCOVERY_INTERFACE SOUNDTOUCH_DEVICES
export SERVICE_URL SERVICE_CA
export BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
exec "${SCRIPT_PATH}" "$@"
}
write_env_file() {
log "Updating env file: ${ENV_FILE}"
local vars=(
"PORT=${HTTP_PORT}"
"BIND_ADDR=${BIND_ADDR}"
"DISCOVERY_INTERFACE=${DISCOVERY_INTERFACE}"
"SOUNDTOUCH_DEVICES=${SOUNDTOUCH_DEVICES}"
"SERVICE_URL=${SERVICE_URL}"
"SERVICE_CA=${SERVICE_CA}"
)
if [[ ! -f "${ENV_FILE}" ]]; then
for entry in "${vars[@]}"; do
echo "${entry}" >> "${ENV_FILE}"
done
else
for entry in "${vars[@]}"; do
local key="${entry%%=*}"
local val="${entry#*=}"
if ! grep -q "^${key}=" "${ENV_FILE}"; then
echo "${key}=${val}" >> "${ENV_FILE}"
fi
done
fi
chmod 0640 "${ENV_FILE}"
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
}
write_systemd_unit() {
log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=Bose SoundTouch Web UI
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
EnvironmentFile=${ENV_FILE}
ExecStart=${BIN_PATH}
Restart=on-failure
RestartSec=2
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
EOF
}
reload_enable_start() {
log "Reloading systemd, enabling and starting service"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
systemctl restart "${SERVICE_NAME}.service"
log "Verifying service health..."
local health_url="http://localhost:${HTTP_PORT}/health"
local max_retries=5
local count=0
local success=false
while [[ $count -lt $max_retries ]]; do
if curl -fs "$health_url" >/dev/null 2>&1; then
success=true
break
fi
echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)"
sleep 2
count=$((count+1))
done
if [[ "$success" = true ]]; then
log "✅ soundtouch-web is healthy and responding!"
else
log "⚠️ Service started but did not respond at $health_url within timeout."
log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50"
fi
}
show_status() {
log "Service status"
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
log "Listening socket (:${HTTP_PORT})"
ss -tulpn | grep -E ":${HTTP_PORT}\b" || true
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
log "Firewall check (UFW is active)"
if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW"; then
log "⚠️ UFW is active but port ${HTTP_PORT} might be blocked."
log "Run: sudo ufw allow ${HTTP_PORT}/tcp"
else
log "✅ UFW rule for port ${HTTP_PORT} appears to be in place."
fi
fi
cat <<EOF
Open in your browser:
http://<pi-ip>:${HTTP_PORT}/
soundtouch-web is a control panel — you can stop it when not in use:
sudo systemctl stop ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
Logs:
journalctl -u ${SERVICE_NAME}.service -e --no-pager
EOF
}
main() {
need_root
ensure_cmd systemctl
ensure_cmd ss
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
apt_install_if_missing curl
fi
self_update "$@"
ensure_user_group
ensure_dirs
download_binary
write_env_file
write_systemd_unit
reload_enable_start
show_status
}
main "$@"
+46 -6
View File
@@ -10,7 +10,7 @@ set -euo pipefail
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.107.0 \
# VERSION=v0.111.3 \
# HOSTNAME_FQDN=soundtouch.local \
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
@@ -18,7 +18,7 @@ set -euo pipefail
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.107.0
# sudo bash install.sh v0.111.3
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
@@ -28,11 +28,16 @@ set -euo pipefail
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.107.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
# Release to install. Empty means "resolve the latest release" (see
# resolve_version). Pass a tag/number as $1 or VERSION=... to pin a release.
VERSION="${1:-${VERSION:-}}"
# Normalize version prefix for an explicitly provided version.
if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
# Used only when the latest-release lookup fails (offline / rate-limited).
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
@@ -117,7 +122,7 @@ detect_arch_asset() {
download_url_for() {
local asset="$1"
# Release asset pattern used by you earlier:
# soundtouch-service-v0.107.0-linux-armv7
# soundtouch-service-v0.111.3-linux-armv7
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
}
@@ -176,6 +181,40 @@ download_binary() {
log "Installed binary to ${BIN_PATH}"
}
resolve_version() {
# When no explicit version was given, resolve the latest release tag by
# following the documented stable redirect:
# https://github.com/<owner>/<repo>/releases/latest
# which 302-redirects to .../releases/tag/vX.Y.Z. We read the final URL and
# take the tag from it. Falls back to FALLBACK_VERSION on any failure
# (offline, rate-limited, no usable curl/wget).
if [[ -n "$VERSION" ]]; then
return
fi
local latest_url="https://github.com/${GH_REPO}/releases/latest"
log "Resolving latest release via ${latest_url}"
local effective="" tag=""
if command -v curl >/dev/null 2>&1; then
effective="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null)" || true
else
# wget: don't follow the redirect, read the Location header instead.
effective="$(wget -S --max-redirect=0 -O /dev/null "$latest_url" 2>&1 \
| awk 'tolower($1) ~ /location:/ {print $2}' | tr -d '\r' | tail -1)" || true
fi
tag="${effective##*/}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+ ]]; then
[[ "$tag" =~ ^v ]] || tag="v${tag}"
VERSION="$tag"
log "Latest release is ${VERSION}"
else
VERSION="$FALLBACK_VERSION"
log "⚠️ Could not resolve latest release; falling back to ${VERSION}"
fi
}
self_update() {
# If we are already a self-update re-exec, don't do it again
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
@@ -364,6 +403,7 @@ main() {
apt_install_if_missing curl
fi
resolve_version
self_update "$@"
ensure_user_group
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-player uninstaller (systemd, headless)
#
# Reverses scripts/raspberry-pi/install-player.sh: stops and disables the
# systemd service, removes the unit, binary, and config directory.
#
# Usage:
# sudo bash uninstall-player.sh
#
# Notes:
# - soundtouch-player is stateless (no data directory) — nothing to preserve.
# - The shared soundtouch:soundtouch user/group is removed only when no other
# soundtouch-{service,player,web} install remains on this host.
# - Safe to re-run; every step tolerates already-missing pieces.
# ==============================================================================
SERVICE_NAME="${SERVICE_NAME:-soundtouch-player}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-player}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-player}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
stop_remove_service() {
log "Stopping and disabling ${SERVICE_NAME}.service"
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
local unit="/etc/systemd/system/${SERVICE_NAME}.service"
if [[ -f "${unit}" ]]; then
log "Removing systemd unit: ${unit}"
rm -f "${unit}"
fi
systemctl daemon-reload
systemctl reset-failed "${SERVICE_NAME}.service" 2>/dev/null || true
}
remove_binary() {
if [[ -e "${BIN_PATH}" || -e "${BIN_PATH}.old" ]]; then
log "Removing binary: ${BIN_PATH} (and ${BIN_PATH}.old)"
rm -f "${BIN_PATH}" "${BIN_PATH}.old"
fi
}
remove_config() {
if [[ -d "${CONFIG_DIR}" ]]; then
log "Removing config directory: ${CONFIG_DIR}"
rm -rf "${CONFIG_DIR}"
fi
}
# Remove the shared soundtouch:soundtouch user/group only when no other
# soundtouch-{service,player,web} install remains on this host.
remove_user_group_if_unused() {
local n
for n in service player web; do
if [[ -f "/etc/systemd/system/soundtouch-${n}.service" ]] || \
[[ -e "/usr/local/bin/soundtouch-${n}" ]]; then
log "Keeping ${SERVICE_USER}:${SERVICE_GROUP} — still used by soundtouch-${n}."
return
fi
done
if id -u "${SERVICE_USER}" >/dev/null 2>&1; then
log "No other soundtouch installs remain; removing user ${SERVICE_USER}"
userdel "${SERVICE_USER}" 2>/dev/null || true
fi
if getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
groupdel "${SERVICE_GROUP}" 2>/dev/null || true
fi
}
main() {
need_root
ensure_cmd systemctl
stop_remove_service
remove_binary
remove_config
remove_user_group_if_unused
log "✅ soundtouch-player has been removed."
}
main "$@"
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-web uninstaller (systemd, headless)
#
# soundtouch-web was renamed to soundtouch-player. This uninstaller removes a
# leftover soundtouch-web install created by the (now deprecated) install-web.sh:
# it stops and disables the systemd service, removes the unit, binary, and config
# directory.
#
# Usage:
# sudo bash uninstall-web.sh
#
# Notes:
# - soundtouch-web is stateless (no data directory) — nothing to preserve.
# - The shared soundtouch:soundtouch user/group is removed only when no other
# soundtouch-{service,player,web} install remains on this host.
# - Safe to re-run; every step tolerates already-missing pieces.
# ==============================================================================
SERVICE_NAME="${SERVICE_NAME:-soundtouch-web}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-web}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-web}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
stop_remove_service() {
log "Stopping and disabling ${SERVICE_NAME}.service"
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
local unit="/etc/systemd/system/${SERVICE_NAME}.service"
if [[ -f "${unit}" ]]; then
log "Removing systemd unit: ${unit}"
rm -f "${unit}"
fi
systemctl daemon-reload
systemctl reset-failed "${SERVICE_NAME}.service" 2>/dev/null || true
}
remove_binary() {
if [[ -e "${BIN_PATH}" || -e "${BIN_PATH}.old" ]]; then
log "Removing binary: ${BIN_PATH} (and ${BIN_PATH}.old)"
rm -f "${BIN_PATH}" "${BIN_PATH}.old"
fi
}
remove_config() {
if [[ -d "${CONFIG_DIR}" ]]; then
log "Removing config directory: ${CONFIG_DIR}"
rm -rf "${CONFIG_DIR}"
fi
}
# Remove the shared soundtouch:soundtouch user/group only when no other
# soundtouch-{service,player,web} install remains on this host.
remove_user_group_if_unused() {
local n
for n in service player web; do
if [[ -f "/etc/systemd/system/soundtouch-${n}.service" ]] || \
[[ -e "/usr/local/bin/soundtouch-${n}" ]]; then
log "Keeping ${SERVICE_USER}:${SERVICE_GROUP} — still used by soundtouch-${n}."
return
fi
done
if id -u "${SERVICE_USER}" >/dev/null 2>&1; then
log "No other soundtouch installs remain; removing user ${SERVICE_USER}"
userdel "${SERVICE_USER}" 2>/dev/null || true
fi
if getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
groupdel "${SERVICE_GROUP}" 2>/dev/null || true
fi
}
main() {
need_root
ensure_cmd systemctl
stop_remove_service
remove_binary
remove_config
remove_user_group_if_unused
log "✅ soundtouch-web has been removed."
cat <<'EOF'
soundtouch-web is the old name for soundtouch-player. To install the current
control panel instead:
curl -fsSL -o install-player.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-player.sh
sudo bash install-player.sh
EOF
}
main "$@"
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-service uninstaller (systemd, headless)
#
# Reverses scripts/raspberry-pi/install.sh: stops and disables the systemd
# service, removes the unit, binary, and config directory.
#
# Usage:
# sudo bash uninstall.sh # remove service, KEEP the data directory
# sudo PURGE_DATA=true bash uninstall.sh # also delete the data directory
# sudo bash uninstall.sh --purge # same as PURGE_DATA=true
#
# Notes:
# - The data directory (${DATA_DIR}) holds your datastore: presets, device
# registrations, and certificates. It is PRESERVED by default; deleting it is
# opt-in via PURGE_DATA=true (or --purge).
# - The shared soundtouch:soundtouch user/group is removed only when no other
# soundtouch-{service,player,web} install remains on this host.
# - Safe to re-run; every step tolerates already-missing pieces.
# ==============================================================================
PURGE_DATA="${PURGE_DATA:-false}"
for arg in "$@"; do
case "$arg" in
--purge) PURGE_DATA="true" ;;
esac
done
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-service}"
DATA_DIR="${DATA_DIR:-/var/lib/soundtouch-service}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
stop_remove_service() {
log "Stopping and disabling ${SERVICE_NAME}.service"
systemctl disable --now "${SERVICE_NAME}.service" 2>/dev/null || true
local unit="/etc/systemd/system/${SERVICE_NAME}.service"
if [[ -f "${unit}" ]]; then
log "Removing systemd unit: ${unit}"
rm -f "${unit}"
fi
systemctl daemon-reload
systemctl reset-failed "${SERVICE_NAME}.service" 2>/dev/null || true
}
remove_binary() {
if [[ -e "${BIN_PATH}" || -e "${BIN_PATH}.old" ]]; then
log "Removing binary: ${BIN_PATH} (and ${BIN_PATH}.old)"
rm -f "${BIN_PATH}" "${BIN_PATH}.old"
fi
}
remove_config() {
if [[ -d "${CONFIG_DIR}" ]]; then
log "Removing config directory: ${CONFIG_DIR}"
rm -rf "${CONFIG_DIR}"
fi
}
handle_data_dir() {
if [[ ! -d "${DATA_DIR}" ]]; then
return
fi
if [[ "${PURGE_DATA}" == "true" ]]; then
log "Removing data directory: ${DATA_DIR}"
rm -rf "${DATA_DIR}"
else
log "Preserving data directory: ${DATA_DIR}"
echo " This holds your datastore (presets, device registrations, certs)."
echo " To delete it as well, run: sudo rm -rf ${DATA_DIR}"
echo " (or re-run this uninstaller with --purge / PURGE_DATA=true)"
fi
}
# Remove the shared soundtouch:soundtouch user/group only when no other
# soundtouch-{service,player,web} install remains on this host.
remove_user_group_if_unused() {
local n
for n in service player web; do
if [[ -f "/etc/systemd/system/soundtouch-${n}.service" ]] || \
[[ -e "/usr/local/bin/soundtouch-${n}" ]]; then
log "Keeping ${SERVICE_USER}:${SERVICE_GROUP} — still used by soundtouch-${n}."
return
fi
done
if id -u "${SERVICE_USER}" >/dev/null 2>&1; then
log "No other soundtouch installs remain; removing user ${SERVICE_USER}"
userdel "${SERVICE_USER}" 2>/dev/null || true
fi
if getent group "${SERVICE_GROUP}" >/dev/null 2>&1; then
groupdel "${SERVICE_GROUP}" 2>/dev/null || true
fi
}
main() {
need_root
ensure_cmd systemctl
stop_remove_service
remove_binary
remove_config
handle_data_dir
remove_user_group_if_unused
log "✅ soundtouch-service has been removed."
}
main "$@"