Compare commits

...
106 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.8 6c8a50c049 feat(cli): opt-in hardening for setup enable-ssh (--close-17000, --authorized-key) (refs #471)
Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):

- --close-17000: blocks port 17000 from the LAN. Manager.Close17000 remounts /
  read-write, persists an idempotent iptables rule in
  /etc/init.d/Firewalls/update_iptables (keyed on a marker), and applies it
  immediately; loopback access is kept.
- --authorized-key <pubkey>: Manager.InstallAuthorizedKey writes the key to
  /home/root/.ssh/authorized_keys so root SSH no longer relies on the
  empty-password login.

Both run over the SSH the enable step just opened. Default output reminds the
user that 17000 is left open and how to close it. Unit tests cover the
firewall command sequence and the key upload path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:19 +02:00
Tobias GesellchenandClaude Opus 4.8 b7009a50eb feat(cli): setup enable-ssh — bootstrap SSH via the port-17000 envswitch trick (refs #471)
Adds `soundtouch-cli setup enable-ssh`, the first iteration of foob61451's #471:
turn on SSH on a speaker that has no prior SSH access and without a USB
recovery stick, then fall into the migration / CA-install flow we already have.

Mechanism (new Manager methods, reusing the existing telnet :17000 client):
- EnableSSHViaTelnet sends `envswitch boseurls set "<url>;touch
  /tmp/remote_services;/etc/init.d/sshd start" "<url>/update"`. The injected
  shell commands run when the speaker next parses its boseurls (~60s), starting
  sshd. The URL is only the vehicle for the injection — it does NOT need a live
  server, so this works before any AfterTouch service exists.
- WaitForSSHPort polls :22 until sshd is up.
- ResetBoseURLs restores a clean marge URL afterwards.
- Persistence reuses the existing EnsureRemoteServices (writes the marker over
  the now-open SSH so it survives reboot).

CLI flow: inject → wait for :22 → reset clean URLs → persist. `--service-url`
is optional (placeholder used otherwise; set real URLs later via migration).
Securing/closing port 17000 is deliberately OPT-IN and not done here. Unit
tests pin the exact injected/reset command strings and the double-quote guard.

This lands in -cli first (cheapest to iterate); the future soundtouch-app can
reuse the same Manager methods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:19 +02:00
Tobias GesellchenandClaude Opus 4.8 843ec732d5 fix(service): route embedded player TTS self-call over loopback
The embedded player's TTS proxy made a server-side call back to the
service over the public ServiceURL. When that URL is HTTPS with the
service's self-signed CA, the call failed with "x509: certificate
signed by unknown authority" — the service didn't trust its own CA.

Route the player's own server-side self-calls to the service's loopback
HTTP listener instead (new WebApp.InternalServiceURL, used via
proxyServiceURL()). Loopback is plain HTTP, so it needs no CA and works
on HTTP and HTTPS deployments alike, including before the CA is
generated, and it doesn't depend on the public URL being routable from
inside the service. ServiceURL stays public: Play URL bakes it into the
stream URLs the speaker fetches, and the UI displays it.

config.port is always the plain-HTTP listener (http.Serve); TLS lives
on a separate httpsAddr, so the loopback URL can never hit a TLS-only
socket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:02 +02:00
Tobias GesellchenandClaude Opus 4.8 3dd39e85d4 fix(health): set_clock verifies the change and falls back to SSH (refs #345)
The set_clock quick-fix pushed the time via POST /clockTime and reported
success unconditionally. On real hardware (ST10, observed live) the firmware
dispatches POST /clockTime to its read handler (HandleClockGetTime) and
ignores the value: it returns 200 but the clock never moves, so the fix was a
silent no-op that still claimed success.

Now setSpeakerClock:
- tries HTTP POST /clockTime (works on firmware that honours it), then
- verifies by re-reading /clockTime; if the clock did not move, it
- sets the clock over SSH (`date -u -s …`, with a BusyBox positional
  fallback) on an SSH-reachable speaker (root, empty password), and
- verifies again. It only reports success when the clock actually changed;
  otherwise it returns an honest error pointing at the real root cause
  (the speaker can't resolve/reach NTP, so the clock is stuck — restore
  DNS/NTP reachability; a wrong clock breaks HTTPS/TLS).

The HTTP request format itself was already correct (the device's own GET uses
`utcTime`); the problem was never the payload, only that some firmware has no
HTTP setter at all. Durable NTP-side fix (AfterTouch resolving/serving NTP) is
tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:07:21 +02:00
Tobias GesellchenandClaude Opus 4.8 634e16403e security(docker): run the player/web images as non-root (refs #451)
The soundtouch-player image (and its transitional soundtouch-web alias) ran
as root for no reason: the player is stateless, binds an unprivileged port
(8080), and its mDNS/SSDP discovery uses unprivileged multicast. Drop to
USER nobody. Verified the image starts, binds 8080, and discovers as uid
65534.

The soundtouch-service image is left as root for now: it persists to
/app/data (commonly a host-mounted volume whose ownership we can't assume)
and its optional built-in DNS server binds the privileged :53. Making it
non-root needs a chowned data dir plus NET_BIND_SERVICE (or moving DNS off
:53), so it's handled separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:19:34 +02:00
Tobias GesellchenandClaude Opus 4.8 0fd9ad7dad security(docker): non-root soundtouch-service prep, dormant behind a toggle (refs #451)
Lands the groundwork to run the service container as non-root, but keeps it
running as root by default so this is NOT a breaking change yet. Enabling it
(BREAKING) is planned for v1.0.0 and reduced to a one-line flip.

Image prep (all harmless while running as root):
- A fixed non-root user, uid/gid 65532 (aftertouch), with /app chowned to it.
- A cap_net_bind_service file capability on the binary so the optional DNS
  server can still bind :53 as non-root (NET_BIND_SERVICE is in Docker's
  default cap set; no --cap-add needed). Applied after chown so it survives.
- USER ${APP_USER} with ARG APP_USER=root: still root by default. To enable
  non-root, flip the default to "aftertouch" (one line) or build with
  --build-arg APP_USER=aftertouch.

Startup safety net (active now, no-op while writable):
- warnIfDataDirNotWritable probes DATA_DIR and, if it can't write, logs the
  exact `chown -R 65532:65532 <dir>` fix (with the process uid) instead of
  failing later with a cryptic permission error. This is the common snag when
  a non-root container meets a bind-mounted host dir owned by someone else.

Verified: default build runs as root; --build-arg APP_USER=aftertouch runs as
65532, serves /health, writes the data dir; a read-only data dir triggers the
warning + chown hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:03:30 +02:00
Tobias GesellchenandClaude Opus 4.8 f8f783428a docs: update jaas666 SoundTouch Web API reference URL (refs #451)
The community Markdown conversion of the official SoundTouch Web API PDF moved
from jaas666/bose-soundtouch-player-api to jaas666/bose-soundtouch-web-api.
Update the links in the community-tools comparison and related-resources list
so the docs link check passes. (Supersedes an earlier mistaken removal; the
repo was renamed, not deleted.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:33:39 +02:00
Tobias GesellchenandClaude Opus 4.8 bd62fd6658 refactor: rename soundtouch-web to soundtouch-player (transitional alias) (refs #451)
The web player is intrinsically LAN-resident: it reaches speakers directly
and only delegates cloud-only features (e.g. TTS) to a possibly-remote
AfterTouch service via --service-url. That is exactly what a cloud-hosted
soundtouch-service cannot do, so the standalone player binary stays useful
and is not being deprecated. Rename it to state its purpose, with a
transition window so existing downloads keep working.

- cmd/soundtouch-web -> cmd/soundtouch-player; CLI name is now
  soundtouch-player. When the binary is invoked under its old name it prints
  a one-line rename notice (filepath.Base(os.Args[0])).
- Build/release both names from the same source: Makefile (build-player +
  build-web alias, dev-player* targets), Dockerfile (soundtouch-player image
  + transitional soundtouch-web image), release.yml and ci.yml (player +
  web artifacts, checksums, Docker images; release notes announce the
  rename). The soundtouch-web binary, image, and install script remain a
  transitional alias to be dropped in a future release (which will break
  stale fetch scripts and nudge users to the release notes).
- scripts/raspberry-pi/install-player.sh is canonical; install-web.sh keeps
  working but warns.
- Sweep docs, code comments, user-facing strings, and assets
  (soundtouch-web-ui.png, soundtouch-web-tunein.png, soundtouch-web-roadmap.md)
  to soundtouch-player; README documents the rename and why the player
  remains separate from the embedded /app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:33:39 +02:00
Tobias GesellchenandClaude Opus 4.8 2657e5411c style(web): keep the braille logo in brand colours on every bar (refs #451)
The header bars are intentionally monochrome, but the logo was recoloured
along with them (admin forced it white; the player and chooser whitened it
in light mode). Drop the filter on the brand mark only so it stays in its
blue/yellow brand colours as the single accent, while the mono nav icons
still recolour via --nav-icon-filter. Removes the now-unused --logo-filter
var from the chooser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 631422967d feat(web): consistent footers + a shared docs affordance (refs #451)
Unify the three surfaces' footers and rework the documentation link:

- All footers now show the same version-only line (AfterTouch <version>
  (<commit>) • <date>), centered and full-width. The chooser footer no
  longer caps its width or carries a docs link; the admin footer uses the
  same "•" separator as the player and chooser instead of "-".
- The chooser gets a prominent in-body Documentation link with a book
  icon, distinct from the two destination rows (and removed from the top
  bar, which is now brand-only).
- That same book icon becomes a small docs button in the player navbar
  and the admin header bar, so documentation is one click away from every
  surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 d9ef84067e feat(service): keep the chooser reachable via /?chooser (refs #451)
With default_landing set to app or admin, "/" redirects straight there,
which made the chooser (and through it the other surface) unreachable
from the "home" link. Add a "?chooser" override: "/" always serves the
chooser when that query is present, regardless of the configured default.

Point the "home" brand links on the player, the admin console, and the
chooser itself at /?chooser, so "home" always lands on the hub instead of
bouncing back through the default redirect. The bare "/" still honours the
default for direct hits and bookmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 38603a6f03 fix(admin): cap concurrent live-info probes so navigation isn't starved (refs #451)
Root cause of the "navigating away from /admin waits ~30s" report: the
device list refreshed every device's live /info at once. Over HTTP/1.1 a
browser opens only ~6 connections per origin, and it keeps the current
document's in-flight requests (and their sockets) alive until a new
navigation's response begins. With several offline speakers each holding
an /info socket until timeout, all ~6 connections were occupied, so the
next navigation (GET /) could not get a socket until a probe freed one.
The page genuinely waited the full timeout before painting.

Cap the live-info probes at LIVE_INFO_CONCURRENCY (3) via a small mapLimit
helper, leaving sockets free for navigation and other requests. Combined
with the 5s GET timeout, an offline-heavy datastore no longer stalls the
UI. The device table still renders immediately from the datastore; only
the live enrichment is throttled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 d73ce7b559 fix(setup): bound speaker HTTP GETs so offline devices fail fast (refs #451)
Manager.HTTPGet defaulted to http.Get, which uses http.DefaultClient with
no timeout. An offline speaker therefore hung the caller for the OS-level
TCP timeout (~30 s). The admin device list refreshes every device's live
/info on each load (updateDeviceInfo per row), so a handful of offline
speakers each held a request for 30 s. Server-side those run concurrently
and never blocked other routes, but the browser's ~6-connections-per-origin
limit got saturated by the long-held /info requests, which made the whole
admin page (and navigating away from it) feel stuck.

Give HTTPGet a 5 s timeout (liveDeviceHTTPTimeout): ample for a healthy
speaker on the LAN, quick to fail a dead one. Applies to the /info,
/presets, /recents, /sources, inspect, and peer-probe GETs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 a8759f91d5 fix(admin): remove duplicate on-load discovery trigger (refs #451)
The previous commit gated the on-load discovery sweep to a cold start,
but a second, redundant DOMContentLoaded handler still called
triggerDiscovery() ungated on every admin load, so /admin kept kicking
off a full sweep (and its reseed) even with devices already known. The
second handler only duplicated fetchDevices + fetchSettings + the
ungated trigger, all of which the first (gated) handler already does, so
remove it outright. That also drops the duplicate per-device live /info
refresh the second handler caused.

Also drop two em dashes (a code comment and the landing meta description).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 c97f760153 fix(admin): only auto-discover on load when no devices are known (refs #451)
The admin console ran a full discovery sweep on every page load whenever
discovery was enabled (DOMContentLoaded -> triggerDiscovery). With devices
already in the datastore, that re-probed every host (including offline
ones) on each visit, which felt slow and surprising.

Gate the on-load sweep on a cold start only: fetch the cached device list
first, and trigger discovery just when it is empty. With devices known,
rely on the cached list, the periodic sweep, and the explicit Discover
button. fetchDevices now returns the device count for that check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 09113afd87 perf(web): probe datastore hosts concurrently in SeedExtraDevices (refs #451)
SeedExtraDevices probed each datastore host serially via AddDeviceByHost,
whose /info call blocks up to its 10 s timeout for an unknown host. With
offline speakers in the datastore, a re-sync (e.g. the admin page's
discovery sweep on load, or the periodic discovery) stalled for 10 s per
offline device, one after another.

Fan the per-host probes out across goroutines and wait for all of them,
so the seed costs roughly a single timeout regardless of how many devices
are offline. AddDeviceByHost is already registry-safe under concurrency
(covered by TestRegistryConcurrent).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 86878cd23b feat(service): landing chooser at /, shared header + footer (refs #451)
Post-merge, "/" was the admin console with a small text link to the
player. This makes "/" a neutral chooser and unifies the chrome across
all three surfaces (landing, player, admin).

- "/" now serves a lean chooser page (web/landing.html): a calm, self-
  contained page (no framework, inline CSS) that routes to the Player
  (/app) or the Admin & Setup console (/admin), with the console framed
  as the privileged surface. API/speaker clients (non-HTML Accept) still
  get the version JSON from "/" unchanged.
- The admin console moved to /admin (HandleAdmin); its assets and APIs
  are absolute, so it works unchanged at the new path.
- New persisted setting default_landing (chooser|app|admin): when set to
  app or admin, "/" 302-redirects straight there. Exposed in the admin
  Settings tab; defaults to the chooser.
- Shared header: all three carry the same accent bar (braille mark +
  "AfterTouch" + "Bose SoundTouch Toolkit"); the mark is the home link
  back to "/". Shared footer: all three show the same version line
  (the landing fetches /api/setup/version with a tiny vanilla script).

Light/dark and mobile refinements are deliberately left for a later
pass; the admin keeps its existing light-only styling for now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 b861c11d37 feat(web): remove devices from the player UI (refs #451)
The merge of soundtouch-web into soundtouch-service was asymmetric:
manual device *adds* propagated to the player UI (HandleAddManualDevice
notifies, the hook re-seeds + broadcasts), but *removals* did not. The
datastore-removal handler never notified, and the web registry's sync
only ever added entries — its map was append-only, so a removed device
lingered in the player UI until restart.

This adds the missing removal path:

- DELETE /api/control/devices/{id} (HandleDeleteDevice). The registry is
  keyed by host/IP; the datastore by device ID (MAC), so the handler
  resolves one to the other via the connection's DeviceInfo, cascades to
  the datastore through a new RemoveDeviceHook (embedded build only),
  prunes the in-memory entry, and broadcasts the updated list.
- WebApp.RemoveDevice prunes the registry and stops the per-device
  goroutines (status poller + WebSocket reconnect loop) via a new
  done-channel + Close() on DeviceConnection — previously both ran for
  the life of the process.
- Server.RemoveDeviceByID extracts the cross-account lookup + remove from
  HandleRemoveDevice and now fires notifyDevicesChanged, so the admin
  Devices tab removal also propagates to the player UI.
- Player UI: a quiet per-card Remove control (visible on hover), a
  confirm dialog, optimistic prune, and a note that a still-online
  device may reappear after the next discovery scan (honest v1 — no
  ignore-list).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 e82bb43988 refactor(service): web UI shares the service's discovery, no second sweep (refs #451)
Make the datastore the single source of truth for the embedded web UI and
stop running a second mDNS/UPnP stack inside the same process.

- The embedded web app no longer creates its own discovery service. Its
  "discover" action (POST /api/control/discover) now triggers the service's
  own sweep via a new WebApp.TriggerDiscovery hook (wired to
  server.DiscoverDevices), which writes results to the shared datastore.
- DiscoverDevices: when TriggerDiscovery is set it runs the external sweep
  and re-syncs from ExtraDeviceHosts (the datastore) without any own mDNS;
  it only runs its own sweep when given a non-nil discovery service
  (standalone soundtouch-web, unchanged).
- Liveness: server.SetDevicesChangedHook fires after a discovery sweep
  (server.DiscoverDevices) and after a manual add (HandleAddManualDevice);
  the embedded build re-seeds the web registry and broadcasts the updated
  device list, so speakers found by the service's periodic discovery or
  added via /setup appear in the UI without a manual refresh.
- setupRouter no longer takes a web discovery service (it was always nil
  for the service); MountWeb is mounted with a nil discovery service.

Removing devices live still needs a web-registry delete path (the registry
only adds today); that is a separate follow-up. Routes are unchanged, so
the router golden file is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:02:54 +02:00
Tobias GesellchenandClaude Opus 4.8 99b3f5d0aa feat(service): serve the web UI from soundtouch-service (refs #451)
Fold soundtouch-web into soundtouch-service as an additive mount, so a
single process serves both the speaker/cloud-replacement API and the LAN
control UI. No new auth and no opt-in flag: the web surface sits at the
same LAN-trust tier as /setup (which -web already calls without
credentials), and -web is LAN-only by nature.

- newEmbeddedWebApp builds the web app with release metadata, a loopback
  ServiceURL (plain HTTP, no CA needed) for the TTS / Play URL proxy, and
  an initial discovery sweep. setupRouter gains the web app + discovery
  service and mounts the portable surface (MountWeb) additively:
  /api/control/* and /app/* (+ /app/static/*). The service keeps its own
  /, /health and /static; nothing collides. webApp is optional so the
  router unit tests that only exercise the service surface pass nil.
- Manual devices with discovery off: the web app's ExtraDeviceHosts hook
  is pointed at the service datastore (ListAllDevices), and
  SeedExtraDevices (run from DiscoverDevices, i.e. at startup and on each
  /api/control/discover) registers them via the existing AddDeviceByHost.
  So speakers added via /setup show up in the UI even when periodic
  discovery is disabled.
- The admin page at / now links to the player UI at /app; the speaker /
  JSON contract is unchanged.
- Router golden file regenerated: the diff is purely the additive
  /api/control + /app routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:02:54 +02:00
Tobias GesellchenandClaude Opus 4.8 3693cfa65b refactor(web): make the web surface self-contained for embedding (refs #451)
Prepare soundtouch-web to be folded into soundtouch-service as an additive
mount. Two changes, no behaviour change for the standalone binary:

- Move the embedded assets from /static/* to /app/static/*, so the whole
  web UI lives under /api/control + /app and nothing contends with a host
  router's own /static (e.g. the optional Stockholm bridge's root catch-all).
  index.html and app.js asset references are updated in lockstep.
- Split Mount into a portable core and a standalone wrapper. MountWeb
  registers only the portable surface (/app/static/*, /api/control/*,
  /app/*) and nothing outside those subtrees (no /, no /health), so it can
  be mounted into another router additively. Mount (used by cmd/soundtouch-web)
  now calls MountWeb and adds the standalone-only /health and /->/app redirect.

mount_test.go exercises MountWeb (asserts the portable surface owns nothing
outside /api/control + /app) and Mount (asserts it adds / and /health).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:02:54 +02:00
Tobias GesellchenandClaude Opus 4.8 3a038b0129 refactor(web): move the app-wide socket to /api/control/ws (refs #451)
Move the web UI's app-wide event stream (device list, discovery status,
per-device status updates) from top-level /ws to /api/control/ws. It is
the read/event half of the control surface, so it belongs under the same
namespace as the rest of the web API (the per-device socket already sits
at /api/control/devices/{id}/ws). The bundled app.js WebSocket URL is
updated in lockstep.

This brings soundtouch-web's entire HTTP surface under two clean subtrees
(/api/control/* for the API, /app/* for the SPA), so folding -web into
-service becomes a near-additive mount.

mount_test.go now asserts /api/control/ws is registered and top-level /ws
is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:59 +02:00
Tobias GesellchenandClaude Opus 4.8 d9581dc10f refactor(web): group content sources under a /providers infix (refs #451)
Model tunein, radiobrowser, playurl and tts as content "providers" and
give them a uniform /providers namespace, so the surface is consistent
and extensible (Spotify/Amazon slot in later as new providers).

Two kinds of provider operation fall out naturally:

  - Browsable providers (a catalog you search/navigate) expose global
    browse routes:
      GET /api/control/providers/tunein/{search,search/next,navigate,navigate/*}
      GET /api/control/providers/radiobrowser/search
  - Every provider plays on a device via a uniform `play` verb:
      POST /api/control/devices/{id}/providers/tunein/play
      POST /api/control/devices/{id}/providers/radiobrowser/play
      POST /api/control/devices/{id}/providers/url/play      (was play-url)
      POST /api/control/devices/{id}/providers/tts/play       (was speak)

Input providers (url, tts) have no catalog, so they appear only as a
device play. The generic POST /devices/{id}/play (raw ContentItem) stays
the low-level primitive, not a provider. /providers stays a literal
namespace with literal provider children (no {provider} param), so there
is still zero static-vs-param ambiguity.

The bundled api.js is updated in lockstep. mount_test.go now asserts the
provider routes exist and the pre-infix flat paths are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:59 +02:00
Tobias GesellchenandClaude Opus 4.8 cd47ae0c5a refactor(web): serve the SPA under /app/* (refs #451)
Move the soundtouch-web single-page app from top-level page paths
(/devices, /tunein, ...) under one /app subtree, so the whole web UI
lives under /app/* and folding -web into -service stays an additive
mount. The client navigates via component state rather than the URL and
all assets are referenced absolutely (/static/...), so this is a pure
routing change: no frontend edits needed.

The bare root / now redirects into the app (standalone convenience).
When -web is folded into -service, / instead serves a landing page
(admin vs app) and this redirect is replaced.

Extend mount_test.go with TestMountSPARoutes: the SPA resolves under
/app, the old top-level page paths are gone, and / remains only as the
redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:59 +02:00
Tobias GesellchenandClaude Opus 4.8 a16b4babcb refactor(web): nest control API under /api/control/* (refs #451)
Restructure soundtouch-web's control API to the post-merge canonical
shape so folding -web into -service later is a near-additive mount.
Device-scoped actions now nest under /api/control/devices/{id}/...,
making every direct child of /api/control a literal namespace (devices,
tunein, radiobrowser, version, discover) with no static-vs-param sibling
ambiguity. Browse/search endpoints (tunein, radiobrowser) stay global.

This is a direct migration (no dual-mount, no deprecation middleware):
-web's only client is its own bundled frontend, so a reload picks up the
new paths. The bundled api.js/app.js are updated in lockstep.

Add mount_test.go: the first test that exercises Mount() itself. It
walks the registered routes to assert (a) registration never panics and
(b) the invariant that every web /api/* route lives under /api/control/*
so no flat route is left behind. Handler unit tests call handlers
directly with injected params, so their request-path literals were
cosmetic; updated to the new nested shape for accurate documentation.

SPA routes and the main /ws socket are unchanged here; they move in
follow-up steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:59 +02:00
Tobias GesellchenandClaude Opus 4.8 3d67e99b2d docs(architecture): correct merge-overlap analysis + sequence the web migration (refs #451)
Verified the actual overlap between the service and soundtouch-web routers; the
doc's "/, /health, /ws are all collisions" was too broad:

- `/` is the only true collision -> resolve with a landing page (Admin/Setup vs App).
- `/health` is a merge (both define it; standardise on the service's richer body,
  and check nothing depends on the web's {"status":"ok","version"} shape).
- `/ws` and `/static/*` are additive -- the service registers neither.

Sequence the merge to mirror the proven service approach but adapted to -web:
- Migrate `-web` in place to the target shape (`/api/control/*`, `/app/*`) FIRST,
  as a direct restructure -- no dual-mount, no deprecation signal -- because its
  only client is its own bundled frontend (reload-to-fix). The careful
  add-alias-then-deprecate dance stays reserved for the central `-service`.
- The subsequent fold-in is then a near-additive mount plus the `/` landing page
  and `/health` standardisation.

Also: resolve overlaps structurally before merging (a flag that conditionally
registers routes hides a collision, it does not fix it; do not rely on chi to
warn); ship the merged variant behind an opt-in flag whose purpose is optional
testing/feedback (default-off also keeps the surface unexposed until auth lands),
not a collision guard. Note the deprecation signal is already implemented for
/setup and /mgmt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:45 +02:00
Tobias GesellchenandClaude Opus 4.8 30c7599210 feat(service): add a deprecation signal on the legacy /setup and /mgmt paths (refs #451)
So the eventual 1.x removal of the legacy admin paths can be data-driven (cut a
route only once it has gone quiet across real deployments), record usage of the
pre-/api paths without changing their behavior.

- New DeprecatedRouteMiddleware: after serving, counts the hit keyed by
  "METHOD <route-pattern>" and logs a one-time warning per route pointing at the
  /api equivalent. Wired onto the legacy /setup and /mgmt mounts only — NOT the
  /api/* twins, NOT the externally-pinned OAuth callbacks, NOT the Stockholm
  setup-wizard catch-all.
- Counts are exposed in the diagnostic export (deprecated_route_hits), so the
  shared bundles show whether the old paths are still in use.

Legacy paths keep working unchanged. make test-http-client: 95 requests, 0
failed (the suite still exercises /mgmt directly and now emits the one-time
warnings). go test + golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:36:49 +02:00
Tobias GesellchenandClaude Opus 4.8 3d5add3a07 refactor(web): proxy TTS through /api/setup/tts/speak (refs #451)
Point soundtouch-web's TTS proxy at the new canonical /api/setup/tts/speak path
(request URL and doc comment). No behavior change; the legacy path still works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:57:54 +02:00
Tobias GesellchenandClaude Opus 4.8 dbdc75627a refactor(cli): call /api/setup/* from soundtouch-cli (refs #451)
Point the CLI's service calls at the new canonical paths: tts speak
(/api/setup/tts/speak) and the CA bundle fetch (/api/setup/ca.crt), plus the
user-facing message and doc comment. No behavior change; legacy paths still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:57:54 +02:00
Tobias GesellchenandClaude Opus 4.8 21742cfbf6 refactor(health): probe /api/setup/version in the server-URL reachability check (refs #451)
Move the internal self-reachability probe onto the new /api/setup/version path
(updating the doc comment and the unit test accordingly). No behavior change
(the legacy path still works); keeps our own code off the soon-to-be-legacy
/setup/* surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:57:54 +02:00
Tobias GesellchenandClaude Opus 4.8 a5bdd58cb6 refactor(web): point the admin UI at the /api/{setup,mgmt} paths (refs #451)
Switch the bundled admin SPA's requests from the legacy /setup/* and /mgmt/*
paths to the new canonical /api/setup/* and /api/mgmt/* aliases. Behaviour is
unchanged (the aliases serve the same handlers; TestDualRouteEquivalence pins
that), and the legacy paths stay live, so this is a no-break move. The OAuth
callback URLs are not referenced by the SPA and stay at /mgmt regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:57:54 +02:00
Tobias GesellchenandClaude Opus 4.8 734d13d7cb feat(service): dual-mount the admin API under /api/{setup,mgmt} (refs #451)
Route-transition step 1: add /api/setup/* and /api/mgmt/* as purely additive
aliases of the existing /setup/* and /mgmt/* admin-tier routes, registered from
one shared closure so the legacy and new paths stay byte-identical. The old
paths remain live (no-break upgrade); the admin-SPA repoint and the
old-route deprecation signal are deliberate follow-ups.

- /api/mgmt carries the same Basic Auth as /mgmt. The browser OAuth callbacks
  (/mgmt/{spotify,amazon}/callback) are externally-pinned (provider redirect
  URIs) and stay at /mgmt only — not aliased.
- /api/setup serves data only; the Stockholm setup-wizard static catch-all
  (/setup/*) stays under /setup.
- peer-probe is now part of the shared setup registration, so it is served at
  both /setup/peer-probe and /api/setup/peer-probe (previously a one-off
  top-level /setup/peer-probe route).
- New TestDualRouteEquivalence fires the same request at the old and new path
  and asserts identical status + body — the harness that guards each
  dual-routing step.

Frozen speaker contract untouched. Router golden updated.
make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:33:03 +02:00
Tobias GesellchenandClaude Opus 4.8 2dd0143e10 feat(service): add 3 speaker-contract routes for parity (refs #451)
Close the speaker/service-contract gaps found comparing against a reference
implementation — three real Bose routes we did not serve:

- DELETE /streaming/account/{account}/source/{sourceID} — removes a configured
  source from every device of the account (HandleMargeDeleteSource +
  marge.RemoveSourceFromAccount), mirroring the account-level POST add-source.
  Bare 200, empty body. Previously source removal was only reachable via the
  admin /setup surface.
- GET /bmx/tunein — bare TuneIn service descriptor (the registry's `self` link),
  HandleTuneInService. chi routes both /bmx/tunein and /bmx/tunein/.
- GET /core02/svc-bmx-adapter-orion/prod/orion — bare Orion (LOCAL_INTERNET_RADIO)
  adapter descriptor, HandleOrionService.

The two descriptors reuse the existing extractBMXService + applyBMXTemplate
helpers (same {BMX_SERVER}/{MEDIA_SERVER} substitution the registry applies).
Contract tests added (delete_source.http, get_bmx_service_descriptors.http);
router + frozen-coverage goldens updated.

make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 ea6ee3e097 refactor(service): stub the unused /accounts/* mirror with a 501 "report it" handler (refs #451)
Shrink the route surface the #451 refactor must preserve by retiring the
/accounts/{account}/* compatibility mirror. Across the full recording corpus
(all _/backup/*, _/mitm, _/i195, _/issue-94, captures + data/ + tests/, 139k+
.http files) no speaker or app uses the /accounts prefix, and every operation it
offered is served by the /streaming/account/* paths real clients actually use.

- New HandleUnsupported: returns 501 and logs the full request + client IP + a
  "please report this" message, so any real-world use surfaces instead of being
  silently dropped, and the prefix becomes a clean removal candidate.
- Re-point every /accounts/* route to it. The frozen /streaming/* contract is
  left entirely on its real handlers (those stay even where our corpus didn't
  exercise them — absence of capture is not proof of disuse).
- Migrate the integration tests off the /accounts mirror onto their recorded
  /streaming/account/* equivalents (register/unregister/spotify_full_flow), then
  pin the mirror's 501 contract in unsupported_routes.http.
- Router + frozen-route-coverage golden files updated accordingly.

make test-http-client: 91 requests, 0 failed. go test + golangci-lint clean.

Note for release time: call out the intentional /accounts/* 501 breakage in the
release notes' Noteworthy section (use /streaming/account/* instead).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 44f3ccfc18 docs(anonymization): keep the non-conformant 192.168.1.10 example
Revert an over-eager sanitization: this paragraph explains *why* RFC-1918 ranges
make poor placeholders, and deliberately uses 192.168.1.10 as the
non-conformant counter-example. Rewriting it to an RFC-5737 address defeated the
point (192.0.2.10 is obviously a documentation placeholder). Restore the
illustrative bad example.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 9bfe2a1a08 fix(ci): gate http-client tests on mock readiness; address semgrep findings (refs #451)
The integration suite flaked in CI: with three `go run` mocks now compiling
concurrently, the spotify/amazon mocks weren't listening within the fixed
`sleep 10`, so the registration requests at the start of the suite hit a
connection-refused and the "Account exists" assertions (and the cascading amazon
oauth token test) failed. Locally it passed because the mock builds were warm.

Replace the fixed sleep with real readiness gating:
- Add a /healthz endpoint to the spotify, amazon and tunein mocks.
- Give all four CI services (the three mocks + soundtouch-service) a compose
  healthcheck (busybox wget; all images are alpine-based), and make the service
  depend_on the mocks being service_healthy.
- `docker compose up -d --build --wait` blocks until everything is healthy, so
  the JetBrains client only runs against a fully-ready stack.

Also clear the two semgrep advisories on the new TuneIn mock:
- cmd/mock-*: annotate the intentional plaintext ListenAndServe with nosemgrep
  (throwaway loopback/CI test servers, never production).
- pkg/testutils/tunein: sanitize the query-supplied guide id to a safe charset
  before interpolating it into the JSON/XML response (raw-html-format).

make test-http-client: 73 requests, 0 failed (clean testdata, healthcheck-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias Gesellchen 2c2bb54eff chore 2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 51ad72adcf test(service): add frozen-route contract-coverage guard (refs #451)
TestFrozenRouteContractCoverage walks the service router for frozen speaker/app
contract routes (the /streaming, /accounts, /customer, /bmx, /core02, /oauth,
/custom, /media, /updates, /v1, /alexa, /ced prefixes) and checks each is hit by
at least one .http integration test. The set of uncovered frozen routes is
golden-filed (testdata/frozen_routes_uncovered.txt), mirroring the existing
router_routes.txt pattern: adding a frozen route without a test, or a test that
newly covers one, changes the set and fails the guard, forcing a conscious
update. This makes COVERAGE.md a machine-checked invariant rather than a doc
that can silently drift.

Restricted to GET/POST/PUT/DELETE (chi HandleFunc-registered routes otherwise
add CONNECT/TRACE/... noise). golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 7e0573032c chore(sanitize): remove real device ID and personal LAN IPs from tracked files
Per the repo's no-real-data rule (CLAUDE.md), scrub committed files only (the
gitignored _/ local captures are left as-is):

- Real Bose-OUI device ID 08DF1F0BA325 -> placeholder AABBCCDDEE0A across 4 docs
  and 8 Go test files (consistent 1:1 rename; affected packages tested green).
- Personal/topology LAN IPs -> RFC-5737: the lab runbook's AP subnet
  192.168.10.x -> 198.51.100.x (192.0.2.x is already used contrastively there)
  and 192.168.100.1 -> 203.0.113.1; illustrative example IPs in
  ANONYMIZATION-SUMMARY / spotify-overview / TROUBLESHOOTING -> 192.0.2.x.
- Kept factual RFC-1918 range citations (10.0.0.0/8 trusted-proxy example,
  192.168.0.0/16 "all private subnets") since they name the ranges themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 02be18c19c docs(api): reconcile stale endpoint-coverage docs against pkg/client (refs #451)
The device-API coverage docs had drifted from the code. Verified each claim
against pkg/client and corrected:

- UNIMPLEMENTED-ENDPOINTS.md: re-marked endpoints now implemented but still
  listed as candidates — setMusicServiceAccount / removeMusicServiceAccount and
  the stereo-pair group set (getGroup/addGroup/removeGroup/updateGroup); added a
  reconciliation note and clarified this tracks the speaker :8090 API, not the
  service router.
- SUPPORTED-URLS.md: fixed the "Not Yet Implemented" lists (music services,
  presets, stations, navigate, speaker, requestToken/notification/playNotification
  are all implemented), the contradictory storePreset double-listing, the native
  group section, and the System Info over-claim (trackInfo non-functional,
  bluetoothInfo not implemented).
- API-COVERAGE.md: fixed the exec-summary count (18/19 -> 20/21) to match its own
  table and refreshed the date.

Also sanitised a real device ID (08DF1F0BA325 -> placeholder) found in
SUPPORTED-URLS.md, per the repo's no-real-MACs rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias Gesellchen da081f6425 chore 2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 d5b298fc43 test(http-client): pin ignored edges + app/provisioning surface (refs #451)
Two deliberately-unimplemented routes, pinned as "currently ignored" so a future
change to them is conscious:
- GET  /v1/blacklist/{deviceId}  -> 405 (inline stub)
- POST /alexa/certificate        -> 501 (no AWS IoT integration)

App / provisioning surface (app-called, not the speaker data-plane). Shapes come
from the _/mitm capture where one exists, otherwise from the handler (canned /
stub responses):
- GET  /streaming/account/{a}/emailaddress  -> 200 (<emailAddress>, _/mitm)
- GET  /customer/account/{a}                -> 200 (<customer> profile, canned)
- POST /customer/account/{a}                -> 200 (profile update, stub)
- POST /customer/account/{a}/password       -> 200 (password change, stub)

COVERAGE.md gains an app/provisioning section and records the source (mitm vs
handler) for each. make test-http-client: 73 requests, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 e91e7d8ad4 test(http-client): add remaining simple GET->200 cases + fix coverage rows (refs #451)
Two more frozen GET routes that return a static 200:
- GET /bmx/registry/v1/servicesAvailability (embedded availability registry JSON)
- GET /ced/soundtouch/mr4_22097fe2/index.xml (CED firmware-update config; a
  present static file is 200, absent paths 404)

COVERAGE.md: correct the rows that were already covered by the first batch but
left marked as gaps (/v1/auth, /v1/scmudc, orion station, custom playback,
ding, bmx-icons), and record the two new routes. Remaining gaps are the ones
that need an upstream fixture (tunein episode), prior TTS state (media/tts), or
are quirky-status edges.

make test-http-client: 67 requests, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias Gesellchen e7f1e6bfbd chore 2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 a974862b07 test(http-client): pin the ETag conditional-GET (304) contract (refs #451)
The speaker re-polls /full and the device presets with the ETag it last saw and
expects 304 Not Modified when nothing changed. Two self-contained flows capture
the current ETag and replay it via If-None-Match, asserting 304. This pins the
conditional-GET behaviour and the case-sensitive ETag header path (CLAUDE.md).

make test-http-client: 65 requests, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 6efad165f6 test(http-client): mock TuneIn upstream so playback tests run offline (refs #451)
Make the BMX TuneIn integration tests independent of the live TuneIn
(radiotime.com) service, the same way Spotify/Amazon are already mocked.

- pkg/service/bmx: the TuneIn upstream base URLs become configurable vars with
  a SetTuneInEndpoints(opmlBase, apiBase) setter that also registers the host in
  the outbound allowlist. Defaults are unchanged (real radiotime hosts), so
  production behaviour is identical; tests can redirect to a mock.
- cmd/soundtouch-service: new --tunein-opml-url / --tunein-api-url flags
  (TUNEIN_OPML_URL / TUNEIN_API_URL) wired through to SetTuneInEndpoints.
- cmd/mock-tunein + pkg/testutils/tunein: a mock TuneIn server serving Tune.ashx
  (stream URLs) and describe.ashx (name/logo) with RFC-5737 values; unmocked
  endpoints 404 so a test needing them fails loudly.
- docker-compose.ci.yml: add the tunein-mock service and point the service at it.
- tunein_playback_station.http now asserts the mock-served stream URL + name,
  proving the path is offline. tunein_favorite.http covers the local-only
  favorite add/remove (202).
- TUNEIN-MOCK-MISSING.md lists the upstream captures still needed (episode /
  navigate / search) before those routes can be mocked + tested.

make test-http-client: 61 requests, 0 failed. golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 41fccd6f1c test(http-client): cover the group delete lifecycle (refs #451)
create_group.http now captures the new group id from the Location header into
{{groupId}}; delete_group.http then completes the lifecycle by removing that
group (DELETE /group/{groupId} -> 200 with <status>) and exercises the no-id,
account-level teardown form a speaker sends on factory reset
(DELETE /group/ -> 200). Inserted after get_group.http, before device teardown.

make test-http-client: 59 requests, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 603765b644 test(http-client): broaden speaker-contract coverage from recordings (refs #451)
Build the regression net the API route layout note calls for, before any
route refactoring: mine real recorded speaker traffic (Bose_Lisa UA) into a
coverage checklist and fill the high-priority, dependency-free gaps.

- COVERAGE.md: inventory of frozen speaker routes (method + status) mapped to
  covering .http files, with the remaining gaps classified by priority.
- New flows, all asserting status/content-type/structure with the firmware UA:
  - GET  /v1/auth                              (app-key probe)
  - POST /v1/scmudc/{deviceId}                 (telemetry upload)
  - GET  /core02/.../orion/station             (Orion custom-stream adapter)
  - GET  /custom/v1/playback/{encodedURL}      (LOCAL_INTERNET_RADIO / ding)
  - POST /bmx/tunein/v1/report                 (STOP -> {}, START -> nextReportIn)
  - GET  /media/aftertouch-ding.wav            (binary: status + content-type)
  - GET  /media/bmx-icons/{provider}/{file}    (binary: status + content-type)

All request/response values use placeholder / RFC-5737 data; no recorded
bodies are committed. make test-http-client: 57 requests, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 59f7ed5543 docs(architecture): cross-link the API route layout note (refs #451)
- architecture/_index.md: list the section's docs with links.
- reference/CLOUD-API.md: "See also" pointer (service cloud-emulation routes).
- reference/API-ENDPOINTS.md: note distinguishing the speaker device API from
  the service route layout, with a link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 b4b23a3716 docs(architecture): add API route layout and refactoring plan (refs #451)
Architectural reference for the staged API refactoring that precedes the
soundtouch-web / soundtouch-service merge:

- Route classification by client audience and what pins each path (frozen
  firmware contract vs externally-pinned OAuth callbacks vs our movable
  admin/control surface), with service + web route tables.
- Actors model (speaker / app / cloud) and deployment topologies; speaker-direct
  vs data-plane reachability.
- deployment-mode parameter (private/shared/public), trust tiers, auth posture
  (opt-none -> opt-in -> opt-out?), and auth mechanisms (Marge as one auth
  provider like EntraID; native/headless clients via RFC 8252 loopback or a
  headless token; identity in logs).
- /app/* single role-gated app with code-splitting for on-device size.
- Versioning policy: no path versioning; semver with 0.x dual-routing and a 1.x
  cutover that removes obsolete routes.
- Staged migration (add+alias, fold in web, deprecate the binary, observable
  old-route warnings) with a "before 1.x" definition of done.
- Regression safety: contract tests from the frozen recordings, building on the
  existing tests/integration/http-client suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00
Tobias GesellchenandClaude Opus 4.8 4f561944a3 fix(bmx): strip trailing slash from server_url so TuneIn playback routes
A server_url configured with a trailing slash (e.g. http://host:8000/)
flowed verbatim into the BMX registry base ("{BMX_SERVER}/bmx/tunein"),
so speakers were handed "http://host:8000//bmx/tunein" and requested
"//bmx/tunein/v1/playback/station/{id}". The chi router does not match
the doubled-slash path, so TuneIn playback returned 404 and the speaker
reported INVALID_SOURCE. Confirmed from a reporter's diagnostic export.

- Add NormalizeServerURL (trim whitespace + trailing slashes); apply in
  NewServer so the BMX base is always clean.
- Normalize server_url at ingestion in main (flag + persisted) so the
  margeServerUrl/bmxRegistryUrl pushed to speakers stays clean too.
- Normalize in the live settings-update path so a UI-saved trailing slash
  is trimmed before validate/persist.
- Mount chi middleware.CleanPath as a defensive net: any "//" path
  collapses to "/" before routing, regardless of source.
- Regression tests: NormalizeServerURL table + BMX registry must not emit
  "//bmx"/"//media" for a trailing-slash server_url.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:08:41 +02:00
Tobias Gesellchenandlnx01 519526852d Potential fix for code scanning alert no. 308: Log entries created from user input
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-04 22:13:23 +02:00
Tobias GesellchenandClaude Opus 4.8 188c5521b7 fix(datastore): sanitize wrapped errors in malformed-XML logs (CodeQL go/log-injection)
The #458 empty/0-byte resilience logging logged the raw xml.Unmarshal error with
%v. A parse error can echo attacker-controlled file content, so a newline-bearing
error string reached the log unsanitized (CodeQL go/log-injection, medium). Wrap
the error with sanitizeErr (strips \n/\r), the barrier logutil.go documents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:38:12 +02:00
Tobias GesellchenandClaude Opus 4.8 b1a5428ebf fix(datastore): fsync atomicWriteFile for crash-safe durability (#458)
atomicWriteFile wrote a temp file and renamed it, but never fsync'd — so an
unclean power-cut on a journaling NAND filesystem (UBIFS on the speaker's
/mnt/nv) could leave the renamed datastore file present but 0 bytes (the rename
was journalled, the data blocks were not flushed). Now fsync the temp file
before the rename and the parent directory after, via os.Root.OpenFile/Open;
directory fsync is best-effort (unsupported on some filesystems).

Pairs with the read-side resilience fix (#459): durability prevents the 0-byte
files; resilience tolerates any that already exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:38:12 +02:00
Tobias GesellchenandClaude Opus 4.8 d7c3976684 fix(datastore): treat empty/0-byte/unparseable XML as missing → serve defaults (#458)
A power-cut on the speaker's NAND can leave a datastore file present but 0-byte
(a not-yet-flushed atomicWriteFile write). The read paths now treat empty/0-byte/
unparseable Presets/Recents/Sources the same as missing: GetConfiguredSources
serves the managed defaults (so /full self-heals instead of wiping the speaker),
GetPresets/GetRecents return an empty list (no more HTTP 500 on the device-level
endpoints), and HasConfiguredSources reports a 0-byte file as absent (so the
create_default_sources health quick fix is offered again).

Read-side resilience only; the write-side durability fix (fsync in
atomicWriteFile) follows in a separate PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:23:09 +02:00
Tobias Gesellchen c297a90be3 chore: update version to v0.107.0 in docs/scripts 2026-06-04 17:17:09 +02:00
dependabot[bot] de978b4225 ci(deps): bump github/codeql-action from 4.36.0 to 4.36.1
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.0 to 4.36.1.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/7211b7c8077ea37d8641b6271f6a365a22a5fbfa...87557b9c84dde89fdd9b10e88954ac2f4248e463)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 23:46:55 +02:00
dependabot[bot] c7f450b66e ci(deps): bump actions/checkout
Bumps the actions-core group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [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/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 23:29:25 +02:00
Tobias GesellchenandClaude Opus 4.8 3b3cec7e94 feat(health): add speaker_clock check with set_clock quick-fix
A speaker with a wrong clock fails TLS to any HTTPS host because the
certificate appears not-yet-valid or expired (the CURL ErrorCode 60 seen
in #345, where the failing speaker had a wrong clock, the only one of
several speakers that was off, with a failing NTP sync; these speakers
default to the year 2000 at boot until NTP succeeds). Nothing surfaced
this before.

The check reads each speaker's /clockTime and compares its UTC epoch to
the service's epoch. Using the epoch (ClockTime.GetUTC, not GetTime) keeps
the comparison timezone-independent. Tiers: under 60s no finding; 60s-5m
info; 5m-24h warning; 24h-or-more, or a time outside the year 2000..2100
plausibility window, error. Findings note a stale or missing NTP sync.

A set_clock quick-fix on the warning and error findings pushes the current
time to the speaker via POST /clockTime (client.SetClockTime). That call is
plain HTTP on :8090, so it works regardless of the speaker's wrong clock or
TLS state. It is a band-aid: if NTP is still failing the clock drifts again
and resets on reboot, so the confirm dialog and success message point at
restoring time sync as the durable fix. An SSH set-clock fallback is left
for later since the HTTP path is confirmed on firmware 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:29:15 +02:00
Tobias GesellchenandClaude Opus 4.8 c466246dee feat(health): add on-demand DNS-path diagnostics for the #345 speaker-DNS escape
When a speaker resolves the firmware-hardcoded content.api.bose.io through
the operator's own DNS instead of AfterTouch, TuneIn/BMX content requests
escape AfterTouch and fail (CURL 60, or a dead-cloud 404), so the speaker
reports INVALID_SOURCE. The existing dns_sanity check only probes AfterTouch's
own answering side over loopback, so it passes even when no speaker uses
AfterTouch as its resolver. This adds a speaker-side, on-demand check.

dns_speaker_usage:
- pkg/discovery/dns.go tracks distinct non-loopback clients that query an
  intercepted Bose hostname (interceptClients set, populated in recordQuery,
  exposed via InterceptClientIPs()). Loopback is excluded so dns_sanity's own
  probes don't register.
- The check lists each unconfirmed speaker as an info finding with a "Test DNS
  path" quick-fix. It never emits a standing warning, so it does not
  false-positive after a restart (the querier set is in-memory and starts empty).

Active probe (the "Test DNS path" quick-fix; also POST /setup/health/dns-path-probe):
- Sends a /speaker notification carrying a per-probe nonce as the app_key. To
  accept it the speaker must resolve audionotification.api.bosecm.com
  (intercepted) and call back GET /v1/auth with that nonce; the callback
  arriving is direct proof the speaker resolves Bose hosts through AfterTouch.
- HandleSpeakerAuth returns 403 for a matching nonce so the speaker refuses the
  notification (silent, no audio, confirmed on hardware); any other key still
  gets 200 so real TTS is untouched. Reuses resolveTTSHost for SSRF-safe
  targeting; the nonce is never logged. Registered without refresh so the probe
  result stays visible in the Health tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 23:29:15 +02:00
Tobias Gesellchen 055ff1ab1c Bump Golang to 1.26.4
See https://go.dev/doc/devel/release#go1.26.0
2026-06-03 23:13:41 +02:00
dependabot[bot] c31035460f docker(deps): bump golang from 1.26.3-alpine to 1.26.4-alpine
Bumps golang from 1.26.3-alpine to 1.26.4-alpine.

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.26.4-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 23:13:41 +02:00
Tobias Gesellchenandlnx01 a16dcd5e56 Potential fix for pull request finding 'CodeQL / Log entries created from user input'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-02 23:00:58 +02:00
Tobias GesellchenandClaude Opus 4.8 44d16e54da fix(client): log unhandled WebSocket event names instead of empty list
An <updates> frame whose only child is an element the WebSocketEvent
struct doesn't model (e.g. nowSelectionUpdated, sent by SoundTouch 10
firmware around a play action) produced no known event types, so
handleEvent logged "Received unknown event types: []" repeatedly. The
empty list carried no information and flooded soundtouch-web's logs and
the CLI events subscribe output we point people at for debugging.

Capture unmodeled <updates> children by name via an xml:",any" catch-all
on WebSocketEvent and log the actual element names ("[nowSelectionUpdated]"),
skipping frames that carry no child events entirely. A regression test
confirms a modeled event is not also captured as unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:00:58 +02:00
Tobias GesellchenandClaude Opus 4.8 040469a074 feat(web): log playback requests and now_playing error transitions
soundtouch-web had no logging on its play/select paths, which made
issues like #345 (a source rejected by the speaker) hard to diagnose:
a SoundTouch /select returns HTTP 200 even when the source is then
rejected, so the failure only surfaces asynchronously as a now_playing
transition to an error source, and nothing recorded it.

Add two log points:
- logPlaybackRequest: one line per play/select with the resolved
  source, sourceAccount, location and itemName, from all five handlers
  (source-select, device-play, play-url, radiobrowser, tunein). This is
  often the only record of what was actually requested. sourceAccount
  here is an account identifier, not a bearer credential.
- logNowPlayingError: logs when a device's now_playing enters an error
  source (INVALID_SOURCE or any *_ERROR), deduped per transition, which
  is the real signal that a selection failed on the speaker.

The two TuneIn/RadioBrowser handlers now resolve the ContentItem via
stations.ResolveContentItem and select it directly so the log shows the
authoritative outgoing source; the now-unused stations.Play wrapper is
removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:00:58 +02:00
Tobias GesellchenandClaude Opus 4.8 8ea2461265 fix(web): forward account param when selecting a source (#444)
The /api/control/{host}/source handler hardcoded an empty sourceAccount,
so devices that share source="AUX" across multiple jacks (e.g. the ST-5
CD/Aux inputs, disambiguated by AUX/AUX1/AUX2) always received
sourceAccount="AUX" and rejected the wrong jack with internal error 1005.

Read the account query parameter and forward it to SelectSource, matching
what the frontend already sends and what the CLI already does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:08:05 +02:00
Tobias Gesellchen 8232fd1401 chore: update version to v0.104.1 in all installer scripts 2026-05-31 23:43:44 +02:00
Tobias GesellchenandClaude Opus 4.8 d94b1bc067 fix(web): trust service CA and send a known target for TTS
soundtouch-web's "Speak" feature proxies to the AfterTouch service's
/setup/tts/speak endpoint. Two issues blocked it end to end.

1. TLS: the proxy used http.DefaultClient, which trusts only system
   roots, so the HTTPS call to a service using its own self-signed CA
   failed with "x509: certificate signed by unknown authority". Add a
   --service-ca flag (SERVICE_CA env) that loads the CA PEM, appends it
   to the system pool, and uses a custom client for the TTS call.

2. Target: soundtouch-web sent device.Client.Host() (a full base URL
   like http://ip:8090), but the service's SSRF guard exact-matches the
   target against bare datastore IPs, returning "host ... is not a known
   device". Prefer the device ID (the canonical key) and send a bare-IP
   host fallback. Also normalize the incoming host in resolveTTSHost so a
   URL/host:port form still resolves; it still only ever returns a
   datastore IP, so the SSRF guarantee is unchanged.

Adds unit tests for the CA client builder, hostOnly, and resolveTTSHost
(including the preserved unknown-host/device rejections). Documents
--service-ca in the soundtouch-web README and TROUBLESHOOTING guide.
Wires SERVICE_URL and SERVICE_CA (empty defaults) into the Raspberry Pi
install-web.sh env file and documents them in the Pi guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:37:33 +02:00
Tobias GesellchenandClaude Opus 4.8 7051793e81 chore: bump version to v0.104.0 and refresh UI screenshots
Update v0.103.0 -> v0.104.0 across installer scripts, walkthrough docs,
and example go.mod files, and refresh the devices/migration/settings/sync
UI screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:48:24 +02:00
Tobias GesellchenandClaude Opus 4.8 d413bf60ab fix(tts): resolve speak target to a known device IP (SSRF, CodeQL 305)
HandleTTSSpeak passed the request's `host` straight to
client.NewClientFromHost, so the resolved value flowed into the client's
baseURL and the outbound request (client.go post -> httpClient.Do) — a
caller could point the service at an arbitrary host:8090 (SSRF).

resolveTTSHost now always returns an IP looked up from the datastore:
match by deviceId, or by host equal to a known device's IP, and return
that stored IPAddress (never the caller-supplied string). Unknown
hosts/devices are rejected. This both mitigates the SSRF and breaks the
tainted data flow. Adds regression cases for unknown host/device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 40633f33c8 docs(web): trim the Play URL aside from the TTS view's SSRF note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 f2f03a358c feat(web): show Play URL service URL read-only when configured server-side
Mirrors the TTS view's "configured -> locked" behavior. HandlePlayURL
already prefers the server-side --service-url over the client value, so
when it's set the browser field's edits are ignored anyway; reflect that
by rendering it read-only with a note, and editable only as a fallback
when no --service-url is configured. (Play URL has no SSRF: the URL is
handed to the speaker, not fetched by soundtouch-web.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 258cc6198f fix(web): stop TTS proxy from using a browser-supplied service URL (SSRF)
CodeQL flagged "uncontrolled data used in network request": the
soundtouch-web TTS proxy built its outbound request URL from the
client-supplied serviceUrl, letting any LAN caller use the endpoint as an
SSRF proxy. The proxy target must be the operator-configured --service-url.

- handler: use only app.ServiceURL; drop the client-supplied serviceUrl
  field and fallback.
- web TTS view: show the configured service URL read-only with an
  explanation of why it can't be edited here (Play URL differs — its URL
  is handed to the speaker, not fetched by soundtouch-web, so no SSRF).
- api.speak no longer sends serviceUrl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 382c68d2b6 fix(setup): seed audionotification host(s) into /etc/hosts for /speaker TTS
soundcork#104 confirms speakers validate the /speaker audio-notification
app_key against audionotification.api.bosecm.com (100 calls/day on real
Bose). Our /v1/auth shim accepts it, but a host-seeded migration only
worked if the speaker resolved that host to us. DNS interception already
covers it (bosecm.com substring), but the /etc/hosts migration domain
list did not — so the speaker method would fail on hosts-based setups.

Seed both audionotification.api.bosecm.com and the dev variant
(audionotificationdev.api.bosecm.com; firmware may use either) into the
migration /etc/hosts lists, and update the mock fixtures/docs accordingly.
/v1/auth is path-based, so it already answers regardless of which host the
speaker thinks it is calling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 80cfb03f6e feat(tts): default to /speaker playback; drop /v1/auth debug dump
Confirmed working on a real speaker (Bose_Lisa/27.0.6): the speaker GETs
/v1/auth at audionotification.api.bosecm.com (DNS-redirected to us) with
the app_key in an "Apikeyheader" header, and an empty 200 is sufficient.

- Make "speaker" the default playback method (ducks + resumes the current
  playback, supports volume) for the speak endpoint, the CLI --method flag,
  and the web UI button; "radio" remains opt-in.
- Remove the temporary full-request debug dump from /v1/auth now that the
  contract is understood; document it in the handler comment instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 22c3142a79 refactor(cli): move Cloud TTS under speaker tts-cloud, use global --host
Replaces the awkward top-level `tts speak --speaker-host` with a
`speaker tts-cloud` subcommand that sits alongside the existing
`speaker tts` and uses the global --host flag (--device still works as
an alternative). The two are now clearly related: `speaker tts` sends a
Google Translate URL straight to the speaker, while `speaker tts-cloud`
routes through the service for server-side synthesis (Cloud TTS) and
playback. --speaker-host is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 e6d5588b99 feat(tts): add method selector (speaker | radio) to TTS speak
/setup/tts/speak now accepts a "method" field (and the CLI a --method
flag): "radio" (default, LOCAL_INTERNET_RADIO, no app_key, replaces
source) or "speaker" (POST /speaker notification, ducks+resumes, honours
volume). The speaker method defaults the app_key to "aftertouch" when
none is configured, since the speaker validates it via GET /v1/auth which
we answer 200 regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 56c4ae4e2d fix(tts): play via LOCAL_INTERNET_RADIO; accept app_key at /v1/auth
Root cause of the failed TTS playback: the /speaker notification path
makes the speaker validate the app_key via GET /v1/auth against the
service, which returned 404 -> the speaker reports an invalid app key
(HandleInvalidAppKeyCb) and refuses to play. Our /media/tts hosting was
fine all along (confirmed by a direct GET returning the mp3).

Two fixes:

- TTS speak now plays the synthesized clip as a LOCAL_INTERNET_RADIO
  ContentItem via the /custom/v1/playback proxy (the same mechanism the
  "ding" health check uses), which needs no app_key. New
  buildCustomPlaybackURL helper + tts.Service.BaseURL().
- Add GET /v1/auth -> 200 so the /speaker notification path also works
  (we're the cloud replacement; a 404 there is read as "invalid app
  key"). Includes a TEMPORARY full-request debug dump on /v1/auth to
  learn how the speaker presents the app_key; to be removed later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 169c1c5b9f fix(tts): move TTS endpoints from /mgmt to /setup (no Basic Auth)
The TTS speak/config endpoints were under /mgmt (Basic-Auth protected),
but the soundtouch-web proxy and CLI authenticated with their own
mgmt-password default (empty) while the service defaults to "change_me!",
so speaking from -web returned 401.

This was also inconsistent: the Google API key is configured via the
unauthenticated /setup/settings, and Play URL already proxies to /setup,
so gating only TTS playback behind mgmt auth made no sense. Move
/mgmt/tts/{speak,config} to /setup/tts/{speak,config} (LAN-trust, like
the rest of the setup surface), rename the handlers accordingly, and drop
the now-unused mgmt-credential plumbing from soundtouch-web and the CLI
tts command.

Verified: POST /setup/tts/speak now reaches the handler without auth
(502 only because the test speaker IP is unreachable; previously 401).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 8f2939a9a6 feat(tts): configure Google Cloud TTS from the settings UI; group integrations into collapsible panels
The Google Cloud TTS API key (and app_key / provider / language / voice /
volume) can now be set in the service settings page, persisted to
settings.json, and applied at runtime — same model as Spotify/Amazon
(CLI/env wins at startup, else persisted; secrets masked as "***" over
the wire; a save triggers ReinitTTSService without a restart).

To keep the settings page from bloating as integrations grow, Spotify,
Amazon, and Google Cloud TTS are now collapsible <details> panels under
an "Integrations" heading, each showing an Active/Saved/Inactive badge in
its summary that stays visible when collapsed. Adding a future provider
(e.g. Apple Music) is now just another panel.

Provider construction moved from cmd initTTSService into
handlers.Server.ReinitTTSService so the UI can re-apply changes; the
tts-provider flag default is now empty (empty => translate) so a value
saved in the UI can take effect.

Also: the soundtouch-web TTS source view now shows the AfterTouch service
URL with an override (shared with Play URL via localStorage), and
/api/device-speak accepts a serviceUrl override, mirroring Play URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 c852d07da1 feat(tts): add Google Cloud Text-to-Speech via a pluggable provider
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.

The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
  directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.

Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
  configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
  TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
  service via /api/device-speak/{id}.

The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias Gesellchen 80cab16239 chore: update version to v0.103.0 in all installer scripts 2026-05-31 13:25:42 +02:00
Tobias GesellchenandClaude Opus 4.8 bf8ac6c891 docs: codify resolution + GitHub-reference conventions in CLAUDE.md
Add two working conventions to the Communication style section:

- An issue is only "resolved" once the reporter confirms; a merged PR
  or shipped release is not confirmation.
- GitHub's #<id> auto-links to issues and pull requests only, not
  discussions; use the full discussion URL, and avoid # for security
  alerts (it would point at an unrelated issue/PR).

Both recurred often enough in practice to belong in the always-loaded
project instructions rather than only in session memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 13:25:42 +02:00
Tobias GesellchenandClaude Opus 4.8 83c0e999bc feat(service): capture speaker redirect config in diagnostic export
The diagnostic export captured the symptom of #345 (a TuneIn select
escaping to the dead Bose Apigee gateway → BMX_HTTP_ERROR 4501 →
INVALID_SOURCE) but none of the data that decides where a speaker sends
its marge/BMX/streaming traffic, so we couldn't tell whether the request
was ever redirected to AfterTouch.

Collect that per speaker:
- New collectSpeakerRedirectConfig prefers the on-device
  SoundTouchSdkPrivateCfg.xml over SSH (archives raw + parses
  marge/stats/swUpdate/bmxRegistry URLs), and falls back to
  `getpdo CurrentSystemConfiguration` over telnet when SSH is
  unavailable — the same channel the telnet migration uses. Parsed URLs
  and provenance land in diagnostic.json as redirect_config: source
  (ssh|telnet|none), ssh_reachable, and inferred_migration_method
  (telnet when only telnet answered, since xml/hosts/resolv all need SSH).
- Pull redirection-relevant files over SSH: /etc/hosts(.original),
  /etc/resolv.conf, the resolv-method hook, /mnt/nv/remote_services, and
  the pre-migration .original backups (CA bundle and the URL config).
- Dump the speaker firewall (iptables-save; ip6tables-save is empty on
  FW 27.0.6 but harmless) to catch self-inflicted DROP rules (cf. #354).

Export ParseGetpdoConfig from pkg/service/setup and add a test pinning
the field-name contract the export depends on.

Diagnostic-collection only; does not change migration or playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:56:45 +02:00
Tobias GesellchenandClaude Opus 4.8 bf5309f49f feat(cli): show bare station/episode id as its own column in station find
`station find` previously surfaced the id only inside the Location href
(e.g. /v1/playback/station/s228737). Render the bare id (s228737,
p1864248, or radiobrowser UUID) alone in a leading column so it is easy
to copy-paste, with the name beside it and the description plus full
Location indented below. The Location line stays because that path, not
the bare id, is what play/preset commands consume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 11:28:16 +02:00
Tobias Gesellchen d8166facf0 chore: update version to v0.102.0 in all installer scripts 2026-05-30 23:45:48 +02:00
Tobias GesellchenandClaude Opus 4.8 f26176fad4 fix(marge): never persist or serve sources without a resolvable provider id
Root cause of #334's INVALID_SOURCE: a speaker reports device-local slots
(STORED_MUSIC_MEDIA_RENDERER, UPNP) in /sources; AfterTouch imports them
verbatim and re-serves them in /full. PrepareConfiguredSource fills
sourceproviderid only for types in constants.StaticProviders, so these go
out with an empty <sourceproviderid> — a required protobuf field — and the
speaker rejects them as INVALID_SOURCE, which then re-syncs back into the
datastore.

Fix, keyed on the principle (no hardcoded denylist in production):
- HasResolvableProviderID(s): true if the source already carries a provider
  id, or its source-key type resolves via StaticProviders.
- Serve-side guard in getAccountSources: drop any source whose resolved
  sourceproviderid is still empty (generalises the existing AUX/#195 skip).
  Heals already-polluted datastores on the next /full, no resync needed.
- Import-side filter in syncConfiguredSources (marge) and both branches of
  syncSources (setup): drop unresolvable sources before persisting, stopping
  future pollution and the re-import loop.

Tests: reproduction converted to regression test
(TestI334FullOmitsSourcesWithoutProviderID) seeded from a sanitised real
#334 /sources capture; explicit servable/non-servable tables in
TestHasResolvableProviderID. Two pre-existing fixtures that relied on
sources with no provider id were given valid ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:33:19 +02:00
Tobias GesellchenandClaude Opus 4.8 ef9eea57a6 fix(service): forward all TuneIn stream candidates for failover
TuneIn's Tune.ashx returns several stream URLs per station (different
bitrates/CDNs) so a speaker can fail over when one is dead. TuneInPlayback
parsed the full list but forwarded only urls[0], wrapping a single URL in
the audio.streams[] array. When TuneIn listed a dead variant first (e.g.
station s56857 / NDR 2 Niedersachsen, whose aac/low 404s while mp3/128
plays), the speaker had no fallback and dead-ended retrying the 404.

Add BuildCustomStreamResponseFromURLs to emit one Stream per candidate in
provider order (top-level StreamUrl mirrors urls[0] for compatibility),
have the single-URL BuildCustomStreamResponse delegate to it, and forward
the full slice from TuneInPlayback. The other single-URL callers
(PlayCustomStream, the custom-stream handler) are unchanged.

Confirmed on real hardware: the speaker now fails over from the 404'd
aac/low to the working mp3/128 stream and plays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:03:00 +02:00
Tobias GesellchenandClaude Opus 4.8 c7eda7ed7b feat(cli)!: deprecate speaker-based station search in favour of find
The `find` family runs the search inside the CLI, querying the radio
provider's public API directly (no speaker cloud, no soundtouch-service).
Make it the canonical path and deprecate the speaker-based search family.

- Add `find-tunein` and `find-radiobrowser` siblings; refactor the find
  actions onto a shared `runFind` helper (all support `--more`).
- Rename the unreleased `search-radiobrowser` to `find-radiobrowser`.
- Deprecate `search`, `search-tunein`, `search-pandora`, `search-spotify`:
  they keep working but print a stderr deprecation notice (new
  `PrintDeprecation` helper) pointing at the `find*` replacement. Pandora
  and Spotify have no built-in equivalent yet (they need the speaker +
  account), so their notices say so.
- Docs: lead with the `find` family as recommended; mark the speaker-based
  search commands deprecated; drop the misleading "service-side" wording
  in favour of "built-in / queries the provider directly".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:53:40 +02:00
Tobias GesellchenandClaude Opus 4.8 21efb412fe docs(cli): document service-side station find + search-radiobrowser
Add CLI-REFERENCE entries for the new service-side search commands
(`station find --provider tunein|radiobrowser [--more]` and
`station search-radiobrowser`), with a subsection explaining they run
the search in AfterTouch itself — working without the speaker's live
cloud and without a reachable --host. Also document the pre-existing
but undocumented `station list`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:53:40 +02:00
Tobias GesellchenandClaude Opus 4.8 d101e515a9 feat(cli): service-side station search for TuneIn + Radio Browser
Add a provider-neutral station orchestration layer and expose it in the
CLI so TuneIn and Radio Browser search work consistently without
depending on the speaker's (dead) cloud search. Substance of #338.

- pkg/service/stations: new package with Search/SearchNext/Navigate/
  ResolveContentItem/Play over both providers; centralises the
  SourceAccount placeholder guard.
- soundtouchweb: the six TuneIn/Radio Browser handlers become thin
  adapters over the new package (behaviour preserved; bmxpkg retained
  for HandlePlayURL).
- bmx/radiobrowser: add offset/cursor pagination
  (RadioBrowserSearchPage + RadioBrowserSearchNext) mirroring the
  TuneIn opaque-cursor pattern; BmxNext only on full pages.
- marge: classifyLearnedSource gains a provider-39 (RADIO_BROWSER)
  case + classifyAsRadioBrowser helper (candidate fix for #334
  INVALID_SOURCE; location-substring match still to be confirmed
  against a real recording).
- cli: new `station search-radiobrowser` sibling and unified
  `station find --provider tunein|radiobrowser [--more]`. The existing
  generic device-side `station search --source` is kept unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:53:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8defb0b833 docs(troubleshooting): add cross-subnet / VLAN isolation section
Documents the iptables block that SoundTouch firmware (since 2018)
applies to traffic from other subnets, which prevents AfterTouch from
being reachable when the speaker and server are on different VLANs.

Two fixes: targeted ACCEPT rule (from spookie85, discussion #354) and
the simpler DROP-line comment-out (from dekiesel). Also notes the ST20
Series I outbound-port restriction on non-standard ports (gmuth).

Outgoing link kept to our own discussion #354 for attribution; the
external third-party issue link is omitted as it may go stale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:26:40 +02:00
Tobias Gesellchen 6071146851 chore: update version to v0.100.0 in all installer scripts 2026-05-30 11:18:02 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1685b2f442 docs: make docs the single source of truth for install/update/removal flows
Following user feedback (Lang, issue #432 thread) the docs guides now
contain all operational detail — installation, configuration, service
management, logs, updates, and removal — and the scripts READMEs become
thin pointers to the docs rather than the other way around.

RASPBERRY-PI.md: expanded to cover soundtouch-web alongside
soundtouch-service (install, config, port-conflict note, service
management, logs, update, removal, arch auto-detection, security).
scripts/raspberry-pi/README.md: trimmed to a quick-start with the two
one-liners plus a link to the docs guide.

EXTERNAL-HOST-WALKTHROUGH.md Step 7: replaces the vague "download from
Releases" note with the actual install-web.sh one-liner and a link to
RASPBERRY-PI.md#soundtouch-web; adds a non-Pi install option too.

ON-DEVICE-INSTALL-WALKTHROUGH.md: removed both back-references to
scripts/on-device-install/README.md; added self-contained sections for
Updating (with rollback tip), Service management, Logs, and Uninstalling
so the walkthrough is complete without leaving the docs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:15:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 0b97417eeb fix(web): replace emoji control icons with flat inline SVGs
The power, mute, shuffle, and repeat buttons used Unicode emoji (⏻ 🔇
🔀 🔁) which Android/mobile browsers render through the OS emoji font
with platform-specific colour styling, ignoring CSS color entirely.
This caused them to look like colourful emoji badges rather than flat
monochrome controls.

Replace each with an inline SVG using stroke/fill="currentColor" so
they inherit the button's text colour automatically — flat in both light
and dark mode, and correctly inverted when a button is in its active
(accent-background) state without any extra CSS filter.

The .ctrl-btn rule gains display:inline-flex + align-items:center to
vertically centre both text-character (⏮ ⏸ ⏭) and SVG content
consistently. The .volume-icon label in the volume row switches from
an emoji span to the same currentColor SVG at 16 px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 11:06:22 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e5f5e35c01 fix(web): stabilise speaker WebSocket connection and sync stale now-playing
The speaker WebSocket was cycling every ~65 s because the gorilla pong
handler was never set, so the 60-second read deadline in readLoop fired
after each ping cycle (30 s interval + 5 s reconnect = ~65 s loop).
Setting a pong handler that extends the deadline on every pong response
keeps the connection alive indefinitely during quiet periods.

After any (re)connect the Go server now immediately fetches current
device state via HTTP, because Bose speakers do not replay WebSocket
events on new connections — anything that changed during a disconnect
window would otherwise stay stale until the next speaker-side event.

A 30-second periodic HTTP poll per device is added as a backstop for
Spotify Connect track changes that the SoundTouch API does not surface
as nowPlayingUpdated WebSocket events.

On the browser side, track identity (TrackID / ContentItem.Location) is
added to the NowPlaying timer effect deps so the local counter resets
whenever the track changes regardless of start position, and the time
label is clamped to the song total to prevent "4:17 / 4:09" overruns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 10:57:14 +02:00
Tobias Gesellchen c521414eb1 chore: update version to v0.99.0 in all installer scripts 2026-05-29 00:36:07 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a4b4a51cdb feat(web): add Play URL view for custom stream playback
Adds a top-level "Play URL" view (nav icon: link) so users can paste an
arbitrary stream URL and play it on any discovered device — same
browse-globally-pick-device pattern as TuneIn and RadioBrowser.

- pkg/service/bmx: extract BuildOrionLocation (encode side), shared by
  CLI and web handler; check json.Marshal error (errchkjson)
- cmd/soundtouch-cli: use bmxpkg.BuildOrionLocation instead of local
  copy; merge dual LOCAL_INTERNET_RADIO branches to reduce cyclomatic
  complexity (gocyclo)
- cmd/soundtouch-web: add --service-url / SERVICE_URL flag; expose it
  in WebApp.ServiceURL
- soundtouchweb handler: HandlePlayURL wraps raw stream in Orion
  location when ServiceURL is set (client-supplied fallback when not);
  exposes service_url in /api/version for frontend pre-fill
- soundtouchweb mount: POST /api/play-url/{id}, GET /playurl SPA route
- frontend: PlayURL.js component with device-picker overlay; AfterTouch
  URL persisted to localStorage, pre-filled from server when no override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6eb3829888 chore(lint): fix golangci-lint issues in navigation-station-demo
- Add package comment (revive: package-comments)
- Use index-based range loop for stations slice to avoid 160-byte copy
  per iteration (gocritic: rangeValCopy)
- Rename unused client parameters to _ in three stub functions (revive:
  unused-parameter)
- Remove custom min() helper; Go 1.21+ provides a built-in min (revive:
  redefines-builtin-id)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e54738d367 fix(preset): wrap LOCAL_INTERNET_RADIO stream URL in Orion location
The speaker's BMX module calls GET on the stored preset location and
expects a BmxPlaybackResponse JSON from the AfterTouch Orion endpoint.
Storing a bare stream URL (e.g. http://davefmradio.no-ip.org:8000/stream)
causes BMX to receive raw ICY audio, which it cannot parse; playback
silently stays on the previous source and no error is surfaced.

Add --service-url / SOUNDTOUCH_SERVICE_URL to `preset set`. When set
alongside --source LOCAL_INTERNET_RADIO and a raw HTTP(S) location, the
CLI wraps the stream URL in the Orion station endpoint:

  <service-url>/core02/svc-bmx-adapter-orion/prod/orion/station
    ?data=<base64({"name":"…","imageUrl":"…","streamUrl":"…"})>

Without --service-url the command still works but prints a clear warning
explaining why the saved preset is likely to not play, rather than saving
a silently broken location.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias Gesellchen d70e336e52 chore: update version to v0.98.0 in all installer scripts 2026-05-28 23:11:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 adcdc26d8d feat(web): add RPi installer for soundtouch-web + GET /health endpoint
- Add scripts/raspberry-pi/install-web.sh: mirrors install.sh but for
  the stateless soundtouch-web binary (no privileged ports, no data dir,
  no HTTPS). Default port 8080; override via HTTP_PORT at install time.
- Add GET /health to soundtouch-web (handler + mount); returns
  {"status":"ok","version":"…"} — used by the installer's health check
  and by monitoring.
- Update scripts/raspberry-pi/README.md to document both installers side
  by side (installation, config, service management, updates, removal).
- Bump default VERSION to v0.97.0 in all three installer scripts
  (install.sh, install-web.sh, on-device-install/install.sh).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 22:59:35 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a5f5bdb916 fix(group): propagate removeGroup to all members; handle DELETE /group/
Two bugs prevented clean stereo-pair teardown:

1. removeGroup (CLI) only contacted the --host speaker (master). The
   slave never received /removeGroup and stayed stuck in GroupSlave state
   indefinitely, blocking direct playback. Fix: fetch the current group
   first, then send /removeGroup to every member in parallel — mirrors
   the same symmetry as createGroup (issue #252).

2. Speakers send DELETE /streaming/account/{id}/group/ (trailing slash,
   no group ID) during teardown. Master and slave live in different
   accounts, so each deletes its own copy independently. AfterTouch had
   no route for this form → 405. Fix: add DeleteAllGroupsForAccount to
   the datastore (scans Group_*.xml, idempotent if none found) and wire
   DELETE /group and DELETE /group/ to a new HandleMargeDeleteAccountGroups
   handler in both routing blocks.

Confirmed: after the fix both DELETE calls return 200 and the slave
exits GroupSlave state cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:28:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 04f7388051 fix(health): skip fetchHealth re-render for non-resolving quick fixes
Add a refresh policy to the fix registry so the UI can avoid the
unnecessary "Loading…" flash when a quick fix does not change any
check state.

- Registry stores fixEntry{fn, refresh} instead of bare FixFunc.
- RegisterFix (existing callers) keeps refresh=true: resolved
  findings disappear from the list after the fix runs.
- New RegisterFixNoRefresh sets refresh=false: used for persistent
  operator affordances whose success leaves the finding unchanged.
- RunFix now returns (string, bool, error); the bool propagates to
  the healthFixResponse JSON as "refresh".
- play_ding registered via RegisterFixNoRefresh — pressing it never
  resolves the finding, so no re-fetch is needed.
- runQuickFix in script.js gates setTimeout(fetchHealth, 400) on
  data.refresh !== false; absent or true keeps the existing behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:20:58 +02:00
Tobias GesellchenandClaude Sonnet 4.6 db33f7f22e feat(ding): repeat ding 3× by default to survive speaker startup delay
Speakers need a moment to start buffering after receiving a ContentItem;
the first ~2 s of audio is often missed. Repeating the ding 3 times with
0.4 s gaps between each ensures at least one repetition is audible.

- Add Repeat (default 3) and RepeatGapDuration (default 0.40 s) to Options
- Render() appends silence + base audio for each extra repetition
- WithDefaults() fills zero values for the new fields
- Handler exposes ?repeat= (1–10) and ?repeat-gap-ms= query knobs
- Update TestRender_DefaultSizeApproximately52KB → ~229 KB (2.6 s)
- Add TestRender_RepeatProducesLongerAudio

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:20:58 +02:00
Tobias GesellchenandClaude Sonnet 4.6 118e3fc4a0 feat(health): add speaker_ca_bundle integrity check
Two per-device checks run against each speaker's CA bundle via a
single SSH probe round-trip:

  (1) Every PEM block from ca-bundle.crt.original (the factory backup
      written by TrustCACertFromBytes on first CA injection) must be
      present in the live ca-bundle.crt. A missing block means the
      original trust store was truncated, which would break external
      HTTPS (Spotify, Amazon, firmware updates).

  (2) The AfterTouch CA sentinel (# AfterTouch) must be present in
      the live bundle. Without it the speaker rejects AfterTouch's
      TLS cert and migration is effectively inactive.

Both findings carry a QuickFix:
  - FixIDRestoreAndInjectCA: cp .original → live bundle over SSH,
    then TrustCACert to re-inject the AfterTouch CA.
  - FixIDInjectCACert: TrustCACert only (original certs intact).

Graceful degradation:
  - SSH unavailable → SeverityInfo, no fix offered.
  - .original absent (device never had install-ca run) → SeverityWarning,
    suggest install-ca; check (2) still runs.

Infrastructure changes:
  - ssh_probe.go: add ca-bundle.crt.original to probeFilePaths (free
    in the existing single-round-trip batch).
  - setup.go: export ProbeCABundles and RestoreCABundleFromOriginal so
    the handlers package can use them without exposing speakerProbe.
  - Fix executors live in handlers (need setup.Manager) per the
    established boundary used by completeSpeakerPairingFix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 01:25:56 +02:00
217 changed files with 14349 additions and 1372 deletions
+20 -18
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -66,7 +66,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -107,7 +107,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -143,7 +143,9 @@ jobs:
mkdir -p build
for binary in soundtouch-cli soundtouch-service soundtouch-web soundtouch-backup; do
# soundtouch-web is now a transitional alias of soundtouch-player
# (same source); building the player is enough to verify both.
for binary in soundtouch-cli soundtouch-service soundtouch-player soundtouch-backup; do
OUTPUT="build/${binary}-${ARCH_SUFFIX}${EXT}"
echo "Building $OUTPUT"
go build -trimpath -ldflags="-s -w" -o "$OUTPUT" "./cmd/$binary"
@@ -163,7 +165,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -190,7 +192,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Check documentation links
run: |
@@ -248,7 +250,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -303,7 +305,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -360,26 +362,26 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
- name: Extract metadata (tags, labels) for soundtouch-player
id: meta-player
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}-web
images: ghcr.io/${{ github.repository }}-player
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=ref,event=pr,prefix=preview-pr-
type=sha,prefix=preview-sha-,format=short,enable=${{ github.event_name == 'pull_request' }}
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-web Docker image
- name: Build and push soundtouch-player Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-web
target: soundtouch-player
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: ${{ steps.push-check.outputs.should-push == 'true' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
tags: ${{ steps.meta-player.outputs.tags }}
labels: ${{ steps.meta-player.outputs.labels }}
build-args: |
COMMIT=${{ github.sha }}
DATE=${{ steps.build_date.outputs.date }}
@@ -390,7 +392,7 @@ jobs:
if: steps.push-check.outputs.should-push == 'true'
env:
SERVICE_TAGS: ${{ steps.meta-service.outputs.tags }}
WEB_TAGS: ${{ steps.meta-web.outputs.tags }}
PLAYER_TAGS: ${{ steps.meta-player.outputs.tags }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REF_NAME: ${{ github.ref_name }}
@@ -414,12 +416,12 @@ jobs:
done <<< "$SERVICE_TAGS"
echo '```'
echo ""
echo "### soundtouch-web"
echo "### soundtouch-player"
echo ""
echo '```bash'
while IFS= read -r tag; do
[[ -n "$tag" ]] && echo "docker pull $tag"
done <<< "$WEB_TAGS"
done <<< "$PLAYER_TAGS"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+3 -3
View File
@@ -33,14 +33,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Install libpcap (required for Go build)
if: matrix.language == 'go'
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -51,6 +51,6 @@ jobs:
run: go build ./...
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
+54 -14
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
@@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -170,8 +170,12 @@ jobs:
# Build Service
build_binary "soundtouch-service" "./cmd/soundtouch-service"
# Build Web
build_binary "soundtouch-web" "./cmd/soundtouch-web"
# 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"
@@ -181,6 +185,7 @@ jobs:
run: |
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 }}"
@@ -198,6 +203,7 @@ jobs:
generate_checksums "$CLI_NAME"
generate_checksums "$SVC_NAME"
generate_checksums "$PLAYER_NAME"
generate_checksums "$WEB_NAME"
generate_checksums "$BCK_NAME"
@@ -212,6 +218,7 @@ jobs:
path: |
build/soundtouch-cli-v*
build/soundtouch-service-v*
build/soundtouch-player-v*
build/soundtouch-web-v*
build/soundtouch-backup-v*
retention-days: 1
@@ -240,7 +247,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-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-web-*" -o -name "soundtouch-backup-*" \) -exec mv {} release-files/ \;
# Remove empty directories
find . -type d -empty -delete
@@ -255,14 +262,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-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha256sum > checksums.sha256
ls soundtouch-cli-* soundtouch-service-* soundtouch-web-* soundtouch-backup-* | grep -v '\.sha256$' | grep -v '\.sha512$' | xargs sha512sum > checksums.sha512
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
echo "📋 Generated combined checksums:"
cat checksums.sha256
# Verify all expected files are present (binaries only, not checksum files)
EXPECTED_COUNT=28 # 7 platforms * 4 binaries
EXPECTED_COUNT=35 # 7 platforms * 5 binaries (player + its web alias)
ACTUAL_COUNT=$(ls soundtouch-* | grep -v '\.sha256$' | grep -v '\.sha512$' | wc -l)
if [[ $ACTUAL_COUNT -ne $EXPECTED_COUNT ]]; then
@@ -305,7 +312,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
@@ -393,11 +400,15 @@ jobs:
./soundtouch-service
\`\`\`
### SoundTouch Web
### SoundTouch Player (formerly soundtouch-web)
\`\`\`bash
# Start the web app
./soundtouch-web
# 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
@@ -423,7 +434,7 @@ jobs:
- Windows (amd64)
- FreeBSD (amd64)
`soundtouch-cli`, `soundtouch-service`, `soundtouch-web`, and `soundtouch-backup` are included.
`soundtouch-cli`, `soundtouch-service`, `soundtouch-player` (with `soundtouch-web` as a transitional alias), and `soundtouch-backup` are included.
## 🔐 Checksums
@@ -478,6 +489,7 @@ jobs:
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
@@ -506,6 +518,7 @@ jobs:
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
@@ -521,7 +534,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set build date
id: build_date
@@ -563,6 +576,33 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-player
id: meta-player
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}-player
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-player Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-player
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
push: true
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
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -46,7 +46,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -78,7 +78,7 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
sarif_file: semgrep.sarif
continue-on-error: true
@@ -92,7 +92,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.head_ref }}
token: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -15,6 +15,7 @@ dist/
/soundtouch-backup
/soundtouch-cli
/soundtouch-service
/soundtouch-player
/soundtouch-web
/dummy-speaker
/example-mdns
+1 -1
View File
@@ -78,7 +78,7 @@ linters:
linters:
- errcheck
# Carry-over from cmd/soundtouch-web/handlers relocation: same code,
# Carry-over from cmd/soundtouch-player/handlers relocation: same code,
# same waiver. Tighten in a follow-up if/when the package is reviewed.
- path: pkg/service/soundtouchweb/.*\.go
text: "Error return value of.*is not checked"
+12 -2
View File
@@ -18,7 +18,7 @@ Key binaries:
(status, play, presets, groups, migration, …).
- `soundtouch-service` — replacement for `streaming.bose.com`
and the `bmx` services, default port `8000`.
- `soundtouch-web` — Web UI for Radio browsing and device control.
- `soundtouch-player` — Web UI for Radio browsing and device control.
- `soundtouch-backup` — Helper for on-device backup and restore.
Per-session pickup notes live in two local files at the repo root (they are `.gitignore`d and only exist if created during a session):
@@ -105,7 +105,7 @@ retrospective diffing whenever something goes sideways.
cmd/
soundtouch-cli/ # CLI tool for device control
soundtouch-service/ # Local cloud service emulator
soundtouch-web/ # Web UI (TuneIn browser, device control)
soundtouch-player/ # Web UI (TuneIn browser, device control)
soundtouch-backup/ # On-device backup helper
example-*/ # Usage examples
pkg/
@@ -227,6 +227,16 @@ When working with a human user in this repo:
back to whatever you were doing when the user asks something else.
- **Don't substitute assumptions for real information.** When something
is unclear, ask or check, rather than guessing and proceeding.
- **An issue is only "resolved" once the reporter confirms.** Prefer
"candidate fix, awaiting reporter confirmation" over "fixed" or
"closed" until the person who reported it says it works. A merged PR
or a shipped release is not confirmation.
- **Mind GitHub's `#<id>` auto-linking.** `#<id>` links to issues and
pull requests only — it does **not** resolve to discussions. For a
discussion, write the full URL
(`https://github.com/gesellix/Bose-SoundTouch/discussions/<id>`). For
security alerts, write e.g. "CodeQL alert 280" (no `#`) or the full
URL, since `#280` would point at an unrelated issue/PR.
These principles also apply to other AI assistants pointed at this
repo. Tool-specific config dirs (e.g. `.junie/`, `.claude/`) should
+56 -7
View File
@@ -1,5 +1,5 @@
# Build stage
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.4-alpine AS builder
# Declare automatic platform ARGs to make them available in build stage
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
@@ -34,15 +34,15 @@ RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
-o /soundtouch-service ./cmd/soundtouch-service; \
fi
# Build the soundtouch-web
# Build the soundtouch-player (formerly soundtouch-web)
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
-o /soundtouch-web ./cmd/soundtouch-web; \
-o /soundtouch-player ./cmd/soundtouch-player; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" \
-o /soundtouch-web ./cmd/soundtouch-web; \
-o /soundtouch-player ./cmd/soundtouch-player; \
fi
# soundtouch-service image
@@ -50,6 +50,13 @@ FROM alpine:3.23 AS soundtouch-service
RUN apk add --no-cache ca-certificates tzdata
# Non-root prep (dormant). Everything below is set up so the service CAN run
# as a fixed non-root user, but the image still runs as root by default
# (APP_USER below) so this is not a breaking change yet. The UID/GID is pinned
# (65532) so a mounted data volume's ownership stays predictable.
RUN addgroup -g 65532 -S aftertouch \
&& adduser -u 65532 -S -G aftertouch -H -h /app aftertouch
WORKDIR /app
COPY --from=builder /soundtouch-service /app/soundtouch-service
@@ -57,28 +64,70 @@ COPY --from=builder /soundtouch-service /app/soundtouch-service
# Verify the binary works on the target platform
RUN /app/soundtouch-service version || echo "Binary verification complete"
RUN mkdir -p /app/data
# Create the data dir and hand /app to the non-root user.
RUN mkdir -p /app/data && chown -R aftertouch:aftertouch /app
# Allow the non-root process to bind the privileged DNS port (:53) when DNS
# Discovery is enabled, without granting the whole container extra privileges
# at runtime. NET_BIND_SERVICE is in Docker's default capability set, so this
# file capability is effective out of the box (no --cap-add needed). Done
# after chown, which would otherwise clear it; the setcap tool is removed after.
RUN apk add --no-cache --virtual .setcap libcap \
&& setcap 'cap_net_bind_service=+ep' /app/soundtouch-service \
&& apk del .setcap
ENV PORT=8000
ENV DATA_DIR=/app/data
ENV LOG_PROXY_BODY=false
ENV REDACT_PROXY_LOGS=true
# The toggle. Defaults to root, so this image behaves exactly as before and
# the change is non-breaking today. Enabling non-root is planned for v1.0.0
# (BREAKING: a bind-mounted DATA_DIR must then be writable by uid 65532 — the
# service logs the exact chown command at startup if it can't write). To
# enable, either change this default to "aftertouch" (a one-line commit) or
# build with --build-arg APP_USER=aftertouch.
ARG APP_USER=root
USER ${APP_USER}
EXPOSE 8000
ENTRYPOINT ["/app/soundtouch-service"]
# soundtouch-web image
# soundtouch-player image
FROM alpine:3.23 AS soundtouch-player
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /soundtouch-player /app/soundtouch-player
ENV PORT=8080
EXPOSE 8080
# The player is stateless and binds an unprivileged port, so it has no reason
# to run as root. mDNS/SSDP discovery uses unprivileged multicast.
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-web /app/soundtouch-web
COPY --from=builder /soundtouch-player /app/soundtouch-web
ENV PORT=8080
EXPOSE 8080
USER nobody
ENTRYPOINT ["/app/soundtouch-web"]
+57 -27
View File
@@ -17,8 +17,11 @@ BINARY_NAME=soundtouch-cli
BINARY_PATH=./cmd/$(BINARY_NAME)
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
WEB_PATH=./cmd/$(WEB_NAME)
EXAMPLE_MDNS_NAME=example-mdns
EXAMPLE_MDNS_PATH=./cmd/$(EXAMPLE_MDNS_NAME)
EXAMPLE_UPNP_NAME=example-upnp
@@ -52,7 +55,7 @@ AUTH_SERVICE_URL ?= $(BACKEND_URL)
all: check build
build: build-cli build-service build-web build-examples build-favicon-gen build-backup
build: build-cli build-service build-player build-web build-examples build-favicon-gen build-backup
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -64,10 +67,17 @@ build-service:
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(SERVICE_NAME) $(SERVICE_PATH)
build-web:
@echo "Building $(WEB_NAME)..."
build-player:
@echo "Building $(PLAYER_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(BUILDFLAGS) -o $(BUILD_DIR)/$(WEB_NAME) $(WEB_PATH)
$(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)..."
@@ -164,10 +174,8 @@ test-http-client-rotate:
fi
test-http-client:
@echo "Starting services with docker compose..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
@echo "Waiting for services to start..."
@sleep 10
@echo "Starting services with docker compose (waiting for healthchecks)..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build --wait
@echo "Running .http tests..."
@docker run --rm --network soundtouch-test-net \
-v "$(PWD)/tests/integration/http-client:/workdir" \
@@ -177,11 +185,22 @@ test-http-client:
/workdir/spotify_registration.http \
/workdir/amazon_registration.http \
/workdir/create_account.http \
/workdir/get_emailaddress.http \
/workdir/get_customer_profile.http \
/workdir/post_customer_profile.http \
/workdir/register_device.http \
/workdir/post_scmudc_event.http \
/workdir/get_speaker_auth.http \
/workdir/get_blacklist.http \
/workdir/post_alexa_certificate.http \
/workdir/unsupported_routes.http \
/workdir/spotify_full_flow.http \
/workdir/customer_support.http \
/workdir/power_on.http \
/workdir/get_bmx_services.http \
/workdir/get_bmx_services_availability.http \
/workdir/get_bmx_service_descriptors.http \
/workdir/get_ced_index.http \
/workdir/get_sourceproviders.http \
/workdir/get_software_update.http \
/workdir/get_soundtouch_updates.http \
@@ -190,8 +209,15 @@ test-http-client:
/workdir/post_oauth_token_amazon.http \
/workdir/get_provider_settings.http \
/workdir/tunein_playback_station.http \
/workdir/post_tunein_report.http \
/workdir/tunein_favorite.http \
/workdir/get_orion_station.http \
/workdir/get_custom_playback.http \
/workdir/get_media_ding.http \
/workdir/get_bmx_icon.http \
/workdir/set_preset_6.http \
/workdir/get_presets.http \
/workdir/get_presets_conditional.http \
/workdir/delete_preset_6.http \
/workdir/set_preset_5.http \
/workdir/post_recent.http \
@@ -199,11 +225,14 @@ test-http-client:
/workdir/get_account_presets.http \
/workdir/get_account_devices.http \
/workdir/get_account_sources.http \
/workdir/delete_source.http \
/workdir/get_api_versions.http \
/workdir/post_musicprovider_is_eligible.http \
/workdir/get_full_account.http \
/workdir/get_full_account_conditional.http \
/workdir/create_group.http \
/workdir/get_group.http \
/workdir/delete_group.http \
/workdir/rename_device.http \
/workdir/unregister_device.http \
--report; \
@@ -315,17 +344,17 @@ dev-scan-http: build-examples
@echo "Scanning for HTTP mDNS services..."
$(BUILD_DIR)/$(SCANNER_NAME) -service _http._tcp -v
dev-web: build-web
@echo "Starting web UI (default port 8080)..."
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME)
dev-player: build-player
@echo "Starting web player (default port 8080)..."
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME)
dev-web-port: build-web
@echo "Starting web UI on custom port..."
dev-player-port: build-player
@echo "Starting web player on custom port..."
@if [ -z "$(PORT)" ]; then \
echo "Usage: make dev-web-port PORT=8888"; \
echo "Usage: make dev-player-port PORT=8888"; \
exit 1; \
fi
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -port $(PORT)
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -port $(PORT)
dev-backup: build-backup
@echo "Running backup tool..."
@@ -339,18 +368,19 @@ dev-backup-local: build-backup
@echo "Running local backup (auto-discover)..."
$(BUILD_DIR)/$(BACKUP_NAME) local --discover
dev-web-host: build-web
@echo "Starting web UI with specific host..."
dev-player-host: build-player
@echo "Starting web player with specific host..."
@if [ -z "$(HOST)" ]; then \
echo "Usage: make dev-web-host HOST=192.0.2.10"; \
echo "Usage: make dev-player-host HOST=192.0.2.10"; \
exit 1; \
fi
cd cmd/soundtouch-web && ../../$(BUILD_DIR)/$(WEB_NAME) -host $(HOST)
cd cmd/soundtouch-player && ../../$(BUILD_DIR)/$(PLAYER_NAME) -host $(HOST)
install: build-cli build-service build-web build-backup
install: build-cli build-service build-player build-web 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/
@@ -511,9 +541,9 @@ help:
@echo " dev-backup - Build and show backup tool help"
@echo " dev-backup-cloud - Build and run cloud backup (prompts for credentials)"
@echo " dev-backup-local - Build and run local backup (auto-discover speakers)"
@echo " dev-web - Build and run web UI (default port 8080)"
@echo " dev-web-port - Build and run web UI on custom port (PORT=8888)"
@echo " dev-web-host - Build and run web UI with specific device (HOST=ip)"
@echo " dev-player - Build and run web player (default port 8080)"
@echo " dev-player-port - Build and run web player on custom port (PORT=8888)"
@echo " dev-player-host - Build and run web player with specific device (HOST=ip)"
@echo " install - Install binaries to GOPATH/bin"
@echo " clean - Clean build artifacts"
@echo " release - Create release binaries"
@@ -538,8 +568,8 @@ help:
@echo " make dev-upnp-timeout TIMEOUT=10s"
@echo " make dev-scan-all"
@echo " make dev-scan-soundtouch"
@echo " make dev-web"
@echo " make dev-web-port PORT=8888"
@echo " make dev-web-host HOST=192.0.2.10"
@echo " make dev-player"
@echo " make dev-player-port PORT=8888"
@echo " make dev-player-host HOST=192.0.2.10"
@echo " make test"
@echo " make build-all"
+5 -3
View File
@@ -70,11 +70,13 @@ See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/C
---
### soundtouch-web
### soundtouch-player
A standalone web UI for device control — play, pause, volume, preset selection, real-time status — served from a local Go binary. Complements `soundtouch-service` when you want a dedicated device-control interface separate from the setup/admin UI.
> 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`.
See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
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.
See the [soundtouch-player README](cmd/soundtouch-player/README.md) for usage.
---
+3
View File
@@ -17,6 +17,9 @@ func main() {
log.Printf("Starting mock Amazon LWA server on port %d", *port)
// Plaintext HTTP is intentional: this is a throwaway test mock that only
// runs on the loopback / CI compose network, never in production.
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), amazon.NewAmazonHandler()); err != nil {
log.Fatal(err)
}
+3
View File
@@ -17,6 +17,9 @@ func main() {
log.Printf("Starting mock Spotify server on port %d", *port)
// Plaintext HTTP is intentional: this is a throwaway test mock that only
// runs on the loopback / CI compose network, never in production.
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
log.Fatal(err)
}
+26
View File
@@ -0,0 +1,26 @@
// Package main provides a mock TuneIn (radiotime.com) server for testing.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/testutils/tunein"
)
func main() {
port := flag.Int("port", 8080, "Port to listen on")
flag.Parse()
log.Printf("Starting mock TuneIn server on port %d", *port)
// Plaintext HTTP is intentional: this is a throwaway test mock that only
// runs on the loopback / CI compose network, never in production.
// nosemgrep: go.lang.security.audit.net.use-tls.use-tls
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), tunein.NewTuneInHandler()); err != nil {
log.Fatal(err)
}
}
+71 -3
View File
@@ -244,7 +244,10 @@ func renameGroup(c *cli.Context) error {
return nil
}
// removeGroup tears down the device's stereo pair.
// removeGroup tears down the device's stereo pair by sending /removeGroup to
// every member in parallel. Sending it only to the master (as the old code
// did) leaves the slave stuck in GroupSlave state indefinitely — mirrors the
// same symmetry as createGroup (see issue #252 comment there).
func removeGroup(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Removing stereo pair", clientConfig.Host, clientConfig.Port)
@@ -255,11 +258,76 @@ func removeGroup(c *cli.Context) error {
return err
}
if err := stClient.RemoveGroup(); err != nil {
PrintError(fmt.Sprintf("Failed to remove group: %v", err))
// Fetch current group to learn every member's IP before tearing down.
group, err := stClient.GetGroup()
if err != nil {
PrintError(fmt.Sprintf("Failed to read current group: %v", err))
return err
}
if group.IsEmpty() {
fmt.Println("Device is not in a stereo pair — nothing to remove")
return nil
}
// Collect the unique set of member IPs. The master is always reachable
// via clientConfig.Host; the roles carry all members including slaves.
type memberResult struct {
ip string
err error
}
members := make([]string, 0, len(group.Roles.Roles))
seen := map[string]bool{}
for _, role := range group.Roles.Roles {
if role.IPAddress != "" && !seen[role.IPAddress] {
seen[role.IPAddress] = true
members = append(members, role.IPAddress)
}
}
// Always include the addressed host even if the group response omitted IPs.
if !seen[clientConfig.Host] {
members = append(members, clientConfig.Host)
}
results := make([]memberResult, len(members))
var wg sync.WaitGroup
for i, ip := range members {
wg.Add(1)
go func(idx int, host string) {
defer wg.Done()
mc, mcErr := clientForHost(c, host)
if mcErr != nil {
results[idx] = memberResult{ip: host, err: mcErr}
return
}
results[idx] = memberResult{ip: host, err: mc.RemoveGroup()}
}(i, ip)
}
wg.Wait()
anyErr := false
for _, r := range results {
if r.err != nil {
PrintError(fmt.Sprintf("%s /removeGroup failed: %v", r.ip, r.err))
anyErr = true
}
}
if anyErr {
return fmt.Errorf("/removeGroup propagation failed")
}
PrintSuccess("Stereo pair removed")
return nil
+28
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"strings"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
@@ -85,6 +87,7 @@ type presetParams struct {
name string
itemType string
artwork string
serviceURL string
}
// extractPresetParams extracts parameters from CLI context
@@ -97,9 +100,16 @@ func extractPresetParams(c *cli.Context) *presetParams {
name: c.String("name"),
itemType: c.String("type"),
artwork: c.String("artwork"),
serviceURL: strings.TrimRight(c.String("service-url"), "/"),
}
}
// isOrionLocation reports whether location is already an Orion station URL so
// we don't double-wrap it.
func isOrionLocation(location string) bool {
return strings.Contains(location, "/core02/svc-bmx-adapter-orion/")
}
// resolveLocationAndMetadata resolves location and fetches metadata if needed
func resolveLocationAndMetadata(params *presetParams) error {
originalLocation := params.location
@@ -108,6 +118,24 @@ func resolveLocationAndMetadata(params *presetParams) error {
params.source = resolvedSource
params.location = resolvedLocation
// For LOCAL_INTERNET_RADIO, the speaker's BMX module calls GET on the stored
// location expecting a BmxPlaybackResponse JSON (the Orion station format).
// A direct stream URL returns raw audio, which BMX cannot parse, so playback
// silently stays on the previous source.
if params.source == "LOCAL_INTERNET_RADIO" &&
!isOrionLocation(params.location) &&
(strings.HasPrefix(params.location, "http://") || strings.HasPrefix(params.location, "https://")) {
if params.serviceURL != "" {
params.location = bmxpkg.BuildOrionLocation(params.serviceURL, params.name, params.artwork, resolvedLocation)
fmt.Printf(" Wrapped stream URL in Orion location for LOCAL_INTERNET_RADIO\n")
} else {
fmt.Printf(" ⚠️ --service-url not set: storing raw stream URL as location.\n")
fmt.Printf(" The speaker's BMX module expects an Orion station URL, not raw audio.\n")
fmt.Printf(" Re-run with --service-url <https://your-aftertouch-host> to fix this.\n")
}
}
// If metadata (name or artwork) is missing, try to fetch it
if params.name == "" || params.artwork == "" {
var (
+150 -3
View File
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
setupWaitAPCmd(),
setupWaitOnlineCmd(),
setupSSHCheckCmd(),
setupEnableSSHCmd(),
setupRemoteServicesCmd(),
setupInstallCACmd(),
setupMigrateCmd(),
@@ -537,6 +538,152 @@ func setupSSHCheckCmd() *cli.Command {
}
}
func setupEnableSSHCmd() *cli.Command {
return &cli.Command{
Name: "enable-ssh",
Usage: "Bootstrap SSH on a speaker with no prior access via the port-17000 envswitch trick (#471), " +
"then restore clean URLs and persist it",
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service base URL to point the speaker at (e.g. https://192.0.2.10:8443). " +
"Optional: enabling SSH does not need a live server (the injection fires when the speaker " +
"parses its boseurls), so you can omit this now and set the real URLs later via migration",
},
&cli.DurationFlag{
Name: "wait",
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: "no-reset-urls",
Usage: "Skip restoring clean boseurls after SSH is up (leaves the injected marge URL in place)",
},
&cli.BoolFlag{
Name: "no-persist",
Usage: "Skip persisting the remote_services marker (SSH would not survive a reboot)",
},
&cli.StringFlag{
Name: "authorized-key",
Usage: "Opt-in hardening: install this SSH public key for root (key auth instead of the empty-password login). Pass the key text, e.g. --authorized-key \"$(cat id_ed25519.pub)\"",
},
&cli.BoolFlag{
Name: "close-17000",
Usage: "Opt-in hardening: block port 17000 from the LAN (firewall rule applied now + persisted); loopback access is kept",
},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
m := setup.NewManager("", nil, nil)
// The URL is only the vehicle for the command injection; the
// SSH-enable fires when the speaker parses its boseurls, whether
// or not anything answers there. When the user has no service URL
// yet, use a clearly-placeholder value and tell them to set the
// real URLs during migration.
serviceURL := c.String("service-url")
placeholder := serviceURL == ""
if placeholder {
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())
return err
}
fmt.Printf("Waiting up to %s for sshd (:22) to come up...\n", c.Duration("wait"))
if err := setup.WaitForSSHPort(cfg.Host, c.Duration("wait")); err != nil {
PrintError(err.Error())
return err
}
PrintSuccess("SSH is up on " + cfg.Host)
if !c.Bool("no-reset-urls") {
fmt.Println("Restoring clean boseurls (so the marge URL is usable again)...")
rlogs, rerr := m.ResetBoseURLs(cfg.Host, serviceURL)
if rlogs != "" {
fmt.Print(rlogs)
}
if rerr != nil {
PrintError(rerr.Error())
return rerr
}
}
if !c.Bool("no-persist") {
fmt.Println("Persisting the remote_services marker (SSH survives reboot)...")
plogs, perr := m.EnsureRemoteServices(cfg.Host)
if plogs != "" {
fmt.Print(plogs)
}
if perr != nil {
PrintError(perr.Error())
return perr
}
}
if key := c.String("authorized-key"); key != "" {
fmt.Println("Installing authorized_keys for root (key auth)...")
klogs, kerr := m.InstallAuthorizedKey(cfg.Host, key)
if klogs != "" {
fmt.Print(klogs)
}
if kerr != nil {
PrintError(kerr.Error())
return kerr
}
}
closed17000 := c.Bool("close-17000")
if closed17000 {
fmt.Println("Closing port 17000 to the LAN (loopback kept)...")
clogs, cerr := m.Close17000(cfg.Host)
if clogs != "" {
fmt.Print(clogs)
}
if cerr != nil {
PrintError(cerr.Error())
return cerr
}
}
PrintSuccess("Done — SSH enabled on " + cfg.Host + ". From here, the usual migration / CA-install / inspect commands work.")
if placeholder {
fmt.Println("No --service-url was given, so the speaker's boseurls now point at a placeholder; run your migration next to set the real service URLs.")
}
if closed17000 {
fmt.Println("Port 17000 is now blocked from the LAN (loopback kept).")
} else {
fmt.Println("Note: port 17000 is left open (opt-in --close-17000 to block it from the LAN).")
}
return nil
},
}
}
func setupRemoteServicesCmd() *cli.Command {
return &cli.Command{
Name: "remote-services",
@@ -606,7 +753,7 @@ func setupInstallCACmd() *cli.Command {
return err
}
fmt.Printf("Fetched %d bytes of CA PEM from %s/setup/ca.crt\n", len(certPEM), serviceURL)
fmt.Printf("Fetched %d bytes of CA PEM from %s/api/setup/ca.crt\n", len(certPEM), serviceURL)
m := setup.NewManager(serviceURL, nil, nil)
@@ -627,11 +774,11 @@ func setupInstallCACmd() *cli.Command {
}
}
// fetchCACert pulls AfterTouch's CA bundle from /setup/ca.crt. On HTTP 401
// fetchCACert pulls AfterTouch's CA bundle from /api/setup/ca.crt. On HTTP 401
// it prompts interactively for basic-auth credentials (or accepts --auth)
// and retries once.
func fetchCACert(serviceURL, authFlag string) ([]byte, error) {
url := serviceURL + "/setup/ca.crt"
url := serviceURL + "/api/setup/ca.crt"
doRequest := func(user, pass string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
+206
View File
@@ -2,14 +2,23 @@ package main
import (
"fmt"
"net/url"
"path"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
"github.com/urfave/cli/v2"
)
// searchStations handles searching for stations across different sources
func searchStations(c *cli.Context) error {
PrintDeprecation(
"station search",
"It asks the speaker to search, which fails when the speaker's cloud is gone.",
`soundtouch-cli station find --provider tunein --query "<your search>"`,
)
source := c.String("source")
sourceAccount := c.String("source-account")
searchTerm := c.String("query")
@@ -49,6 +58,12 @@ func searchStations(c *cli.Context) error {
// searchTuneIn handles searching TuneIn specifically
func searchTuneIn(c *cli.Context) error {
PrintDeprecation(
"station search-tunein",
"It asks the speaker to search, which fails when the speaker's cloud is gone.",
`soundtouch-cli station find-tunein --query "<your search>"`,
)
searchTerm := c.String("query")
if searchTerm == "" {
@@ -84,6 +99,12 @@ func searchTuneIn(c *cli.Context) error {
// searchPandora handles searching Pandora specifically
func searchPandora(c *cli.Context) error {
PrintDeprecation(
"station search-pandora",
"There is no built-in Pandora search yet (it requires the speaker and your account).",
"",
)
sourceAccount := c.String("source-account")
searchTerm := c.String("query")
@@ -125,6 +146,12 @@ func searchPandora(c *cli.Context) error {
// searchSpotify handles searching Spotify specifically
func searchSpotify(c *cli.Context) error {
PrintDeprecation(
"station search-spotify",
"There is no built-in Spotify search yet (it requires the speaker and your account).",
"",
)
sourceAccount := c.String("source-account")
searchTerm := c.String("query")
@@ -473,3 +500,182 @@ func printStationList(response *models.NavigateResponse, source string) {
fmt.Printf(" • To play a station: Use the location value with 'play content' command\n")
fmt.Printf(" • To save as preset: Use 'preset set' command with the location\n")
}
// playbackID returns the bare station/episode id from a playback href,
// e.g. "/v1/playback/station/s228737" -> "s228737" and
// "/v1/playback/episodes/p1864248?encoded_name=…" -> "p1864248".
// Returns "" when href is empty.
func playbackID(href string) string {
if href == "" {
return ""
}
if i := strings.IndexByte(href, '?'); i >= 0 {
href = href[:i]
}
return path.Base(href)
}
// printBmxNavResults renders a *models.BmxNavResponse to stdout.
// For each section it prints the section name as a header, then each item as a
// leading id column followed by the name, with subtitle and playback location
// indented below. The bare id sits alone in its own column so it is easy to
// copy-paste.
func printBmxNavResults(resp *models.BmxNavResponse) {
if len(resp.BmxSections) == 0 {
fmt.Println(" No results found")
return
}
for _, section := range resp.BmxSections {
if section.Name != "" {
fmt.Printf("\n [%s]\n", section.Name)
}
if len(section.Items) == 0 {
fmt.Println(" (empty)")
continue
}
// Width of the leading id column = widest id in this section.
maxID := 0
for _, item := range section.Items {
if item.Links != nil && item.Links.BmxPlayback != nil {
maxID = max(maxID, len(playbackID(item.Links.BmxPlayback.Href)))
}
}
// Continuation lines align under the name: 4 leading spaces
// + id column + 2-space gap.
indent := strings.Repeat(" ", 4+maxID+2)
for _, item := range section.Items {
id := ""
if item.Links != nil && item.Links.BmxPlayback != nil {
id = playbackID(item.Links.BmxPlayback.Href)
}
fmt.Printf(" %-*s %s\n", maxID, id, item.Name)
if item.Subtitle != "" {
fmt.Printf("%s%s\n", indent, item.Subtitle)
}
if item.Links != nil && item.Links.BmxPlayback != nil {
fmt.Printf("%sLocation: %s\n", indent, item.Links.BmxPlayback.Href)
}
}
}
}
// bmxNavCursor extracts the opaque cursor value from a section's BmxNext link.
// The Href looks like "...?cursor=<value>"; this returns the cursor query param.
// Returns "" when no next link is present.
func bmxNavCursor(section *models.BmxNavSection) string {
if section == nil || section.Links == nil || section.Links.BmxNext == nil {
return ""
}
href := section.Links.BmxNext.Href
if href == "" {
return ""
}
// The cursor is the query parameter named "cursor".
parsed, err := url.Parse(href)
if err != nil {
return ""
}
return parsed.Query().Get("cursor")
}
// runFind performs a built-in station search for the given provider and
// prints the results. The search runs inside the CLI itself, querying the
// radio provider's public API directly — it needs neither the speaker's
// cloud nor a running soundtouch-service. When more is true it follows up
// to three additional result pages while a next cursor is available.
func runFind(provider stations.Provider, label, query string, more bool) error {
if query == "" {
PrintError("Search query is required")
return fmt.Errorf("search query cannot be empty")
}
fmt.Printf("Searching %s for: %s\n", label, query)
resp, err := stations.Search(provider, query)
if err != nil {
PrintError(fmt.Sprintf("Search failed: %v", err))
return err
}
printBmxNavResults(resp)
if !more {
return nil
}
const maxExtraPages = 3
for page := 0; page < maxExtraPages; page++ {
// Find a cursor from any section that has one.
cursor := ""
for i := range resp.BmxSections {
cursor = bmxNavCursor(&resp.BmxSections[i])
if cursor != "" {
break
}
}
if cursor == "" {
break
}
fmt.Printf("\n -- page %d --\n", page+2)
resp, err = stations.SearchNext(provider, cursor)
if err != nil {
PrintError(fmt.Sprintf("Failed to fetch next page: %v", err))
return err
}
printBmxNavResults(resp)
}
return nil
}
// findStations is the action for the unified `station find` with
// --provider / --query / --more.
func findStations(c *cli.Context) error {
providerStr := c.String("provider")
var (
provider stations.Provider
label string
)
switch strings.ToLower(providerStr) {
case "tunein":
provider, label = stations.ProviderTuneIn, "TuneIn"
case "radiobrowser":
provider, label = stations.ProviderRadioBrowser, "Radio Browser"
default:
PrintError(fmt.Sprintf("Unknown provider %q: must be 'tunein' or 'radiobrowser'", providerStr))
return fmt.Errorf("unknown provider: %s", providerStr)
}
return runFind(provider, label, c.String("query"), c.Bool("more"))
}
// findTuneIn is the action for `station find-tunein` (built-in TuneIn search).
func findTuneIn(c *cli.Context) error {
return runFind(stations.ProviderTuneIn, "TuneIn", c.String("query"), c.Bool("more"))
}
// findRadioBrowser is the action for `station find-radiobrowser`
// (built-in Radio Browser search).
func findRadioBrowser(c *cli.Context) error {
return runFind(stations.ProviderRadioBrowser, "Radio Browser", c.String("query"), c.Bool("more"))
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/urfave/cli/v2"
)
// ttsCloudCmd is the `speaker tts-cloud` subcommand. Unlike `speaker tts`
// (which sends a Google Translate URL straight to the speaker), this routes
// through the AfterTouch service, which synthesizes the audio with the
// configured provider (e.g. Google Cloud TTS), hosts it, and plays it on the
// speaker. It therefore needs --service-url. Target the speaker with the global
// --host, or with --device (resolved to an IP by the service).
func ttsCloudCmd() *cli.Command {
return &cli.Command{
Name: "tts-cloud",
Usage: "Speak text via the AfterTouch service (Google Cloud TTS), synthesized server-side",
Description: "Routes through the AfterTouch service (requires --service-url), which\n" +
"synthesizes the audio with the configured provider, hosts it, and plays it\n" +
"on the speaker. Target the speaker with the global --host or with --device.\n\n" +
"Contrast with 'speaker tts', which sends a Google Translate URL directly to\n" +
"the speaker without involving the service.",
Flags: append(CloudCommonFlags,
&cli.StringFlag{
Name: "text",
Aliases: []string{"t"},
Usage: "Text to speak",
Required: true,
},
&cli.StringFlag{
Name: "device",
Aliases: []string{"d"},
Usage: "Target device ID (the service resolves it to an IP); alternative to --host",
},
&cli.StringFlag{
Name: "language",
Aliases: []string{"l"},
Usage: "Language code (provider-specific; defaults to the service setting)",
},
&cli.StringFlag{
Name: "voice",
Usage: "Voice name (Google Cloud TTS; ignored by the translate provider)",
},
&cli.IntFlag{
Name: "volume",
Aliases: []string{"v"},
Usage: "Playback volume (0-100, 0 = service default; only honoured by --method speaker)",
},
&cli.StringFlag{
Name: "method",
Usage: "Playback method: 'speaker' (/speaker notification, ducks+resumes, supports volume) or 'radio' (LOCAL_INTERNET_RADIO, no app_key, replaces source)",
Value: "speaker",
},
),
Action: ttsCloud,
}
}
func ttsCloud(c *cli.Context) error {
serviceURL := strings.TrimRight(c.String("service-url"), "/")
device := c.String("device")
host := c.String("host") // global flag
if device == "" && host == "" {
return fmt.Errorf("one of --host or --device is required")
}
payload := map[string]interface{}{"text": c.String("text")}
if device != "" {
payload["deviceId"] = device
}
if host != "" {
payload["host"] = host
}
if l := c.String("language"); l != "" {
payload["language"] = l
}
if v := c.String("voice"); v != "" {
payload["voice"] = v
}
if c.IsSet("volume") {
payload["volume"] = c.Int("volume")
}
if m := c.String("method"); m != "" {
payload["method"] = m
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, serviceURL+"/api/setup/tts/speak", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
PrintSuccess(fmt.Sprintf("Spoke %q", c.String("text")))
return nil
}
+16
View File
@@ -340,6 +340,22 @@ func PrintWarning(message string) {
fmt.Printf("⚠️ %s\n", message)
}
// PrintDeprecation prints a deprecation notice to stderr (so it does not
// pollute piped stdout output). reason explains why the command is going
// away; newUsage is an optional replacement example — pass "" when there
// is no replacement yet.
func PrintDeprecation(command, reason, newUsage string) {
fmt.Fprintf(os.Stderr, "⚠️ '%s' is deprecated and will be removed in a future release.\n", command)
if reason != "" {
fmt.Fprintf(os.Stderr, " %s\n", reason)
}
if newUsage != "" {
fmt.Fprintf(os.Stderr, " Use instead:\n %s\n", newUsage)
}
}
// showVersionInfo displays detailed version information including build details
func showVersionInfo(_ *cli.Context) error {
fmt.Printf("%s version %s\n", os.Args[0], version)
+73 -4
View File
@@ -385,6 +385,11 @@ func main() {
Name: "artwork",
Usage: "Artwork URL",
},
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service HTTPS URL (e.g. https://soundtouch.local). Required for LOCAL_INTERNET_RADIO: the speaker's BMX module calls GET on the preset location and expects an Orion JSON response, not raw audio. When provided, the stream URL is automatically wrapped in the Orion station endpoint.",
EnvVars: []string{"SOUNDTOUCH_SERVICE_URL"},
},
},
Before: RequireHost,
},
@@ -584,9 +589,72 @@ func main() {
Aliases: []string{"st"},
Usage: "Search and manage stations",
Subcommands: []*cli.Command{
// Built-in search ("find" family): runs inside the CLI,
// querying the radio provider's public API directly. No
// speaker cloud and no soundtouch-service required.
{
Name: "find",
Usage: "Find stations directly (built-in tunein or radiobrowser search; no speaker needed)",
Action: findStations,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "provider",
Usage: "Station provider: tunein or radiobrowser",
Value: "tunein",
},
&cli.StringFlag{
Name: "query",
Aliases: []string{"q"},
Usage: "Search query",
Required: true,
},
&cli.BoolFlag{
Name: "more",
Usage: "Follow up to 3 additional result pages when available",
},
},
},
{
Name: "find-tunein",
Usage: "Find TuneIn stations directly (built-in search; no speaker needed)",
Action: findTuneIn,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "query",
Aliases: []string{"q"},
Usage: "Search query",
Required: true,
},
&cli.BoolFlag{
Name: "more",
Usage: "Follow up to 3 additional result pages when available",
},
},
},
{
Name: "find-radiobrowser",
Usage: "Find Radio Browser stations directly (built-in search; no speaker needed)",
Action: findRadioBrowser,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "query",
Aliases: []string{"q"},
Usage: "Search query",
Required: true,
},
&cli.BoolFlag{
Name: "more",
Usage: "Follow up to 3 additional result pages when available",
},
},
},
// Deprecated speaker-based search commands. They ask the
// speaker to search, which fails once its cloud is gone.
// Prefer the "find" family above. Kept for now; each emits
// a deprecation notice on stderr.
{
Name: "search",
Usage: "Search for stations and content",
Usage: "[DEPRECATED] Search via the speaker; use 'station find' instead",
Action: searchStations,
Flags: []cli.Flag{
&cli.StringFlag{
@@ -609,7 +677,7 @@ func main() {
},
{
Name: "search-tunein",
Usage: "Search TuneIn stations",
Usage: "[DEPRECATED] Search TuneIn via the speaker; use 'station find-tunein' instead",
Action: searchTuneIn,
Flags: []cli.Flag{
&cli.StringFlag{
@@ -623,7 +691,7 @@ func main() {
},
{
Name: "search-pandora",
Usage: "Search Pandora stations",
Usage: "[DEPRECATED] Search Pandora via the speaker (no built-in equivalent yet)",
Action: searchPandora,
Flags: []cli.Flag{
&cli.StringFlag{
@@ -642,7 +710,7 @@ func main() {
},
{
Name: "search-spotify",
Usage: "Search Spotify content",
Usage: "[DEPRECATED] Search Spotify via the speaker (no built-in equivalent yet)",
Action: searchSpotify,
Flags: []cli.Flag{
&cli.StringFlag{
@@ -1873,6 +1941,7 @@ func main() {
Action: playNotificationBeep,
Before: RequireHost,
},
ttsCloudCmd(),
{
Name: "help",
Usage: "Show detailed help about speaker functionality",
+4
View File
@@ -0,0 +1,4 @@
soundtouch-player
soundtouch-player-test
soundtouch-web
soundtouch-web-test
@@ -2,7 +2,7 @@
## Overview
The `soundtouch-web` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
The `soundtouch-player` tool provides a modern single-page application (SPA) for controlling Bose SoundTouch devices. Built with a JSON API backend and client-side JavaScript rendering, it offers superior performance and eliminates template rendering issues.
## Architecture
@@ -141,10 +141,10 @@ GET /api/control/{id}/source?name=X # Select source
### Build Commands
```bash
# Build the web application
cd cmd/soundtouch-web
go build -o soundtouch-web
cd cmd/soundtouch-player
go build -o soundtouch-player
# Build all project components (includes soundtouch-web)
# Build all project components (includes soundtouch-player)
make build
# Cross-platform builds
@@ -154,19 +154,19 @@ make build-all
### Testing
```bash
# Run unit tests
go test ./cmd/soundtouch-web/...
go test ./cmd/soundtouch-player/...
# Run with coverage
go test -cover ./cmd/soundtouch-web/...
go test -cover ./cmd/soundtouch-player/...
# Lint checking
golangci-lint run cmd/soundtouch-web/...
golangci-lint run cmd/soundtouch-player/...
```
### Development Server
```bash
# Run development server
cd cmd/soundtouch-web
cd cmd/soundtouch-player
go run main.go -port 8080
# Access the web interface
@@ -177,7 +177,7 @@ open http://localhost:8080
### Command Line Options
```bash
soundtouch-web [options]
soundtouch-player [options]
Options:
-port string Web server port (default "8080")
@@ -186,9 +186,9 @@ Options:
### File Structure
```
cmd/soundtouch-web/
cmd/soundtouch-player/
├── main.go # Application entry point
├── soundtouch-web # Built binary
├── soundtouch-player # Built binary
├── handlers/
│ ├── handlers.go # HTTP request handlers
│ ├── handlers_test.go # Handler tests
@@ -75,29 +75,56 @@ Individual device pages provide full control over:
make build
# Or manually
cd cmd/soundtouch-web
go build -o soundtouch-web
cd cmd/soundtouch-player
go build -o soundtouch-player
```
### Running
```bash
# Run with default settings (port 8080)
./soundtouch-web
./soundtouch-player
# Specify custom port
./soundtouch-web -port 8888
./soundtouch-player -port 8888
# Connect to specific device
./soundtouch-web -host 192.0.2.100
./soundtouch-player -host 192.0.2.100
```
### Command Line Options
```
-port string Web server port (default "8080")
-host string Specific SoundTouch device host (optional, enables single-device mode)
-help Show help information
--port, -p string HTTP port to listen on (default "8080", env PORT)
--bind string Address for the HTTP listener: host, IP, or interface name (env BIND_ADDR)
--interface string Network interface name for mDNS/UPnP discovery (env DISCOVERY_INTERFACE)
--devices strings SoundTouch device IP(s) to add manually, repeatable (env SOUNDTOUCH_DEVICES)
--service-url string AfterTouch service base URL, e.g. https://soundtouch.local (env SERVICE_URL)
--service-ca string Path to the AfterTouch service CA certificate (PEM) to trust (env SERVICE_CA)
--help, -h Show help information
```
### Text-to-Speech (TTS)
TTS synthesis and the Bose `app_key` live in the AfterTouch service, not in
soundtouch-player, so the "Speak" feature proxies to the service's
`/setup/tts/speak` endpoint. To use it, point soundtouch-player at the service
with `--service-url`.
When the service is served over HTTPS with its own self-signed certificate
(the default), soundtouch-player also needs to trust the service's CA, or the
proxied call fails with `x509: certificate signed by unknown authority`. Pass
the CA with `--service-ca`; it is the service's `<dataDir>/certs/ca.crt`:
```bash
soundtouch-player \
--service-url https://soundtouch.fritz.box \
--service-ca /path/to/certs/ca.crt
```
The CA is appended to the system trust store, so a service URL that uses a
publicly trusted certificate keeps working without the flag. The target
speaker must be known to the service (it resolves the speaker against its own
device datastore).
## Usage
### Accessing the Interface
@@ -191,7 +218,7 @@ ws.onmessage = function(event) {
### Project Structure
```
cmd/soundtouch-web/
cmd/soundtouch-player/
├── main.go # Application entry point and SPA routing
├── handlers/ # HTTP and WebSocket handlers
│ ├── handlers.go # JSON API endpoints
@@ -217,7 +244,7 @@ cmd/soundtouch-web/
go test ./...
# Manual testing with multiple devices
./soundtouch-web -port 8080
./soundtouch-player -port 8080
# API testing
curl http://localhost:8080/api/devices
@@ -296,7 +323,7 @@ This UI is based on extensive analysis of captured SoundTouch WebSocket interact
Add verbose logging by setting environment variable:
```bash
export DEBUG=true
./soundtouch-web
./soundtouch-player
```
## Contributing
@@ -1,4 +1,11 @@
// Package main provides a web UI for controlling Bose SoundTouch devices.
// Package main provides soundtouch-player, the LAN-resident web player for
// controlling Bose SoundTouch devices. It reaches speakers directly on the
// local network and optionally delegates cloud-only features (e.g. TTS) to a
// 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.
package main
import (
@@ -8,7 +15,9 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
@@ -46,12 +55,30 @@ 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.
func warnIfInvokedAsWeb() {
if len(os.Args) == 0 {
return
}
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'.")
}
}
func main() {
updateBuildInfo()
warnIfInvokedAsWeb()
app := &cli.App{
Name: "soundtouch-web",
Usage: "Web UI for controlling Bose SoundTouch devices",
Name: "soundtouch-player",
Usage: "LAN web player for controlling Bose SoundTouch devices",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "port",
@@ -75,6 +102,16 @@ func main() {
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
},
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
EnvVars: []string{"SERVICE_URL"},
},
&cli.StringFlag{
Name: "service-ca",
Usage: "Path to the AfterTouch service CA certificate (PEM) to trust for server-side calls such as TTS. Typically the service's <dataDir>/certs/ca.crt. Appended to the system trust store",
EnvVars: []string{"SERVICE_CA"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -108,6 +145,18 @@ func main() {
webApp.Commit = commit
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
if caPath := c.String("service-ca"); caPath != "" {
client, err := soundtouchweb.NewServiceHTTPClient(caPath)
if err != nil {
log.Fatalf("--service-ca: %v", err)
}
webApp.ServiceClient = client
log.Printf("Trusting AfterTouch service CA from %s", sanitizeLog(caPath))
}
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
@@ -0,0 +1,200 @@
package main
import (
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/go-chi/chi/v5"
)
// frozenFirstSegments are the top-level path prefixes that belong to the frozen
// speaker / app contract (category 1a/1b in
// docs/content/docs/architecture/API-ROUTE-LAYOUT.md). Routes under these must
// not change shape across the issue #451 refactor, so each should have at least
// one .http contract test (the suite under tests/integration/http-client/, run
// by `make test-http-client`). Movable surfaces (/setup, /mgmt, /web) and infra
// (/, /health, /docs, /favicon.ico) are intentionally excluded.
var frozenFirstSegments = map[string]bool{
"streaming": true,
"accounts": true,
"customer": true,
"bmx": true,
"bmx-icons": true,
"core02": true,
"oauth": true,
"custom": true,
"media": true,
"updates": true,
"v1": true,
"alexa": true,
"ced": true,
}
func coverageFirstSegment(p string) string {
p = strings.TrimPrefix(p, "/")
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[:i]
}
return p
}
// patternToRegexp converts a chi route pattern into an anchored regexp:
// `{param}` becomes a single path segment (`[^/]+`) and `*` becomes `.*`.
func patternToRegexp(pattern string) *regexp.Regexp {
var b strings.Builder
b.WriteString("^")
for i, seg := range strings.Split(pattern, "/") {
if i > 0 {
b.WriteString("/")
}
switch {
case seg == "*":
b.WriteString(".*")
case strings.HasPrefix(seg, "{") && strings.HasSuffix(seg, "}"):
b.WriteString("[^/]+")
default:
b.WriteString(regexp.QuoteMeta(seg))
}
}
b.WriteString("$")
return regexp.MustCompile(b.String())
}
// loadHTTPClientRequests extracts (method, path) pairs from every .http file in
// the integration suite. `{{host}}` is stripped (leaving a leading `/`), query
// strings are dropped, and `{{var}}` template segments are left intact (they
// contain no slash, so they match a `[^/]+` route segment).
func loadHTTPClientRequests(t *testing.T, dir string) [][2]string {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read http-client dir %s: %v", dir, err)
}
reqLine := regexp.MustCompile(`^\s*(GET|POST|PUT|DELETE|PATCH|HEAD)\s+(\S+)`)
var out [][2]string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".http") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatalf("read %s: %v", e.Name(), err)
}
for _, line := range strings.Split(string(data), "\n") {
m := reqLine.FindStringSubmatch(line)
if m == nil {
continue
}
url := strings.ReplaceAll(m[2], "{{host}}", "")
if i := strings.IndexByte(url, '?'); i >= 0 {
url = url[:i]
}
if !strings.HasPrefix(url, "/") {
continue
}
out = append(out, [2]string{m[1], url})
}
}
return out
}
// TestFrozenRouteContractCoverage enforces that every frozen-contract route the
// service registers is exercised by at least one .http integration test. The
// set of *uncovered* frozen routes is golden-filed: adding a new frozen route
// without a test (or adding a test that newly covers one) changes the set and
// fails this test, forcing a conscious update of the golden file. It is the
// machine-checked companion to tests/integration/http-client/COVERAGE.md.
func TestFrozenRouteContractCoverage(t *testing.T) {
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
r := setupRouter(server, nil, nil)
httpRequests := loadHTTPClientRequests(t, filepath.Join("..", "..", "tests", "integration", "http-client"))
// Only the request methods the contract suite actually exercises. Routes
// registered via chi HandleFunc carry every method (CONNECT/TRACE/...); those
// extra verbs are noise for coverage purposes.
meaningfulMethods := map[string]bool{
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true, http.MethodDelete: true,
}
var uncovered []string
walkFunc := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
if !meaningfulMethods[method] {
return nil
}
if !frozenFirstSegments[coverageFirstSegment(route)] {
return nil
}
re := patternToRegexp(route)
for _, req := range httpRequests {
if req[0] == method && re.MatchString(req[1]) {
return nil
}
}
uncovered = append(uncovered, fmt.Sprintf("%-7s %s", method, route))
return nil
}
if err := chi.Walk(r, walkFunc); err != nil {
t.Fatalf("walk routes: %v", err)
}
sort.Strings(uncovered)
output := strings.Join(uncovered, "\n") + "\n"
const goldenPath = "testdata/frozen_routes_uncovered.txt"
actualPath := "testdata/frozen_routes_uncovered.actual.txt"
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
t.Fatalf("write actual: %v", err)
}
golden, err := os.ReadFile(goldenPath)
if os.IsNotExist(err) {
if err := os.WriteFile(goldenPath, []byte(output), 0644); err != nil {
t.Fatalf("create golden: %v", err)
}
t.Logf("created golden %s with %d uncovered frozen routes", goldenPath, len(uncovered))
return
}
if err != nil {
t.Fatalf("read golden: %v", err)
}
if string(golden) != output {
t.Errorf("Frozen-route contract coverage changed.\n"+
"A frozen route either lost its .http test or a new one was added without one.\n"+
"Review and, if intended, update %s from %s.", goldenPath, actualPath)
}
}
@@ -0,0 +1,47 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
)
// TestDeprecatedRouteSignal verifies the legacy admin paths are counted (and the
// new /api/* twins are not), so the diagnostic export can show whether the old
// paths are still in use before they are removed in a future major release.
func TestDeprecatedRouteSignal(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
r := setupRouter(server, nil, nil)
ts := httptest.NewServer(r)
defer ts.Close()
hit := func(path string) {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
_ = resp.Body.Close()
}
hit("/setup/version") // legacy — counted
hit("/setup/version") // legacy again — count increments
hit("/api/setup/version") // new canonical — must NOT be counted
hits := server.DeprecatedRouteHits()
if got := hits["GET /setup/version"]; got != 2 {
t.Errorf("legacy GET /setup/version hits = %d, want 2", got)
}
if _, tracked := hits["GET /api/setup/version"]; tracked {
t.Errorf("/api/setup/version must not be tracked as deprecated; hits=%v", hits)
}
}
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
)
// TestDualRouteEquivalence verifies the issue #451 step-1 aliasing invariant:
// each admin-tier route served at both its legacy path and the new /api/* path
// returns an identical response (same handler, same middleware). It fires the
// same request at the old and new path and asserts equal status + body.
//
// The cases use endpoints whose body does not embed per-request time/random
// values, so the only thing that can differ is the routing — which is exactly
// what we want to pin while the routes are dual-mounted.
func TestDualRouteEquivalence(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
r := setupRouter(server, nil, nil)
ts := httptest.NewServer(r)
defer ts.Close()
cases := []struct {
method string
oldPath string
newPath string
}{
{http.MethodGet, "/setup/version", "/api/setup/version"},
{http.MethodGet, "/setup/settings", "/api/setup/settings"},
{http.MethodGet, "/setup/tts/config", "/api/setup/tts/config"},
{http.MethodGet, "/setup/logging-settings", "/api/setup/logging-settings"},
{http.MethodGet, "/setup/interaction-stats", "/api/setup/interaction-stats"},
{http.MethodGet, "/setup/dns-discoveries", "/api/setup/dns-discoveries"},
// /mgmt is Basic-Auth'd; without credentials both paths must reject
// identically — that pins the auth gate is mirrored onto /api/mgmt too.
{http.MethodGet, "/mgmt/accounts/", "/api/mgmt/accounts/"},
{http.MethodGet, "/mgmt/spotify/accounts", "/api/mgmt/spotify/accounts"},
{http.MethodGet, "/mgmt/amazon/accounts", "/api/mgmt/amazon/accounts"},
}
for _, c := range cases {
t.Run(c.method+" "+c.newPath, func(t *testing.T) {
oldStatus, oldBody := doEquivReq(t, ts.URL, c.method, c.oldPath)
newStatus, newBody := doEquivReq(t, ts.URL, c.method, c.newPath)
if oldStatus != newStatus {
t.Errorf("status mismatch for %s vs %s: old=%d new=%d", c.oldPath, c.newPath, oldStatus, newStatus)
}
if !bytes.Equal(oldBody, newBody) {
t.Errorf("body mismatch for %s vs %s:\n old=%q\n new=%q", c.oldPath, c.newPath, oldBody, newBody)
}
})
}
}
func doEquivReq(t *testing.T, base, method, path string) (int, []byte) {
t.Helper()
req, err := http.NewRequest(method, base+path, nil)
if err != nil {
t.Fatalf("build request %s: %v", path, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body %s: %v", path, err)
}
return resp.StatusCode, body
}
+404 -58
View File
@@ -22,12 +22,14 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
"github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/service/certmanager"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/logbuf"
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/gesellix/bose-soundtouch/pkg/service/spotify"
"github.com/gesellix/bose-soundtouch/pkg/service/stockholm"
"github.com/go-chi/chi/v5"
@@ -176,6 +178,23 @@ func initMusicServices(config serviceConfig, server *handlers.Server) {
}
}
// initTTSService loads the text-to-speech configuration onto the server and
// builds the running service. The provider construction and (re)build logic
// lives on the server so the settings UI can re-apply changes at runtime; see
// handlers.Server.ReinitTTSService.
func initTTSService(config serviceConfig, server *handlers.Server) {
server.SetTTSConfig(
config.ttsProvider,
config.ttsGoogleAPIKey,
config.ttsGoogleEndpoint,
config.ttsAppKey,
config.ttsLanguage,
config.ttsVoice,
config.ttsVolume,
)
server.ReinitTTSService()
}
// logBufferCapacityFromEnv reads SOUNDTOUCH_LOG_BUFFER_LINES and
// returns a positive capacity. Invalid or unset values fall back
// to the default; a value of 0 or negative is treated as "disable"
@@ -358,6 +377,52 @@ func main() {
Usage: "Amazon LWA profile URL (for testing)",
EnvVars: []string{"AMAZON_PROFILE_URL"},
},
&cli.StringFlag{
Name: "tunein-opml-url",
Usage: "TuneIn OPML base URL, covering Tune.ashx/describe.ashx/navigate (for testing / local mock; defaults to opml.radiotime.com)",
EnvVars: []string{"TUNEIN_OPML_URL"},
},
&cli.StringFlag{
Name: "tunein-api-url",
Usage: "TuneIn API base URL, covering search and profile contents (for testing / local mock; defaults to api.radiotime.com)",
EnvVars: []string{"TUNEIN_API_URL"},
},
&cli.StringFlag{
Name: "tts-provider",
Usage: "Text-to-speech provider: 'translate' (Google Translate, no credentials, default) or 'google-cloud' (Google Cloud TTS, needs an API key). Empty falls back to translate; leave unset to let a value saved in the settings UI take effect",
EnvVars: []string{"TTS_PROVIDER"},
},
&cli.StringFlag{
Name: "tts-google-api-key",
Usage: "Google Cloud Text-to-Speech API key (required when --tts-provider=google-cloud)",
EnvVars: []string{"TTS_GOOGLE_API_KEY"},
},
&cli.StringFlag{
Name: "tts-google-endpoint",
Usage: "Google Cloud TTS synthesize endpoint override (for testing)",
EnvVars: []string{"TTS_GOOGLE_ENDPOINT"},
},
&cli.StringFlag{
Name: "tts-language",
Usage: "Default TTS language code. Provider-specific: 'EN'/'DE' for translate, BCP-47 like 'en-US' for google-cloud",
EnvVars: []string{"TTS_LANGUAGE"},
},
&cli.StringFlag{
Name: "tts-voice",
Usage: "Default Google Cloud TTS voice name (e.g. en-US-Neural2-C); ignored by the translate provider",
EnvVars: []string{"TTS_VOICE"},
},
&cli.StringFlag{
Name: "tts-app-key",
Usage: "Bose /speaker app_key used to play TTS notifications on speakers",
EnvVars: []string{"TTS_APP_KEY"},
},
&cli.IntFlag{
Name: "tts-volume",
Usage: "Default TTS playback volume (0-100, 0 = keep current volume)",
Value: 0,
EnvVars: []string{"TTS_VOLUME"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
@@ -445,6 +510,13 @@ func main() {
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
initMusicServices(config, server)
initTTSService(config, server)
// Redirect TuneIn upstream calls when overridden (e.g. to a local
// mock in integration tests); empty values keep the real hosts.
if config.tuneInOpmlURL != "" || config.tuneInAPIURL != "" {
bmx.SetTuneInEndpoints(config.tuneInOpmlURL, config.tuneInAPIURL)
}
// Load and set initial DNS discoveries
dnsDiscoveries, err := ds.LoadDNSDiscoveries()
@@ -516,7 +588,19 @@ func main() {
}
}
r := setupRouter(server, stockholmHandler)
// Embedded web UI (soundtouch-player): LAN control UI under /app, control
// API under /api/control. Same LAN-trust tier as /setup, no auth.
// Server-side self-calls (TTS proxy) use the service's own loopback
// HTTP listener so they never depend on TLS / the service CA.
loopbackHost := config.bindAddr
if loopbackHost == "" {
loopbackHost = "127.0.0.1"
}
internalURL := "http://" + net.JoinHostPort(loopbackHost, config.port)
webApp := newEmbeddedWebApp(server, config.serverURL, internalURL, ds)
r := setupRouter(server, stockholmHandler, webApp)
// Bind the listener before logging so we print the true
// effective port (handles :0 and catches "address already
@@ -602,8 +686,17 @@ type serviceConfig struct {
amazonRedirectURI string
amazonTokenURL string
amazonProfileURL string
tuneInOpmlURL string
tuneInAPIURL string
mgmtUsername string
mgmtPassword string
ttsProvider string
ttsGoogleAPIKey string
ttsGoogleEndpoint string
ttsLanguage string
ttsVoice string
ttsAppKey string
ttsVolume int
migrationEnabled bool
migrationDryRun bool
stockholmDir string
@@ -632,6 +725,9 @@ func loadConfig(c *cli.Context) serviceConfig {
if serverURL == "" {
serverURL = "http://" + hostname + ":" + port
}
// Strip a trailing slash so it cannot leak into the BMX registry base or the
// margeServerUrl/bmxRegistryUrl pushed to speakers during migration.
serverURL = handlers.NormalizeServerURL(serverURL)
httpsPort := c.String("https-port")
@@ -676,8 +772,17 @@ func loadConfig(c *cli.Context) serviceConfig {
amazonRedirectURI := c.String("amazon-redirect-uri")
amazonTokenURL := c.String("amazon-token-url")
amazonProfileURL := c.String("amazon-profile-url")
tuneInOpmlURL := c.String("tunein-opml-url")
tuneInAPIURL := c.String("tunein-api-url")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
ttsProvider := c.String("tts-provider")
ttsGoogleAPIKey := c.String("tts-google-api-key")
ttsGoogleEndpoint := c.String("tts-google-endpoint")
ttsLanguage := c.String("tts-language")
ttsVoice := c.String("tts-voice")
ttsAppKey := c.String("tts-app-key")
ttsVolume := c.Int("tts-volume")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
@@ -714,8 +819,17 @@ func loadConfig(c *cli.Context) serviceConfig {
amazonRedirectURI: amazonRedirectURI,
amazonTokenURL: amazonTokenURL,
amazonProfileURL: amazonProfileURL,
tuneInOpmlURL: tuneInOpmlURL,
tuneInAPIURL: tuneInAPIURL,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
ttsProvider: ttsProvider,
ttsGoogleAPIKey: ttsGoogleAPIKey,
ttsGoogleEndpoint: ttsGoogleEndpoint,
ttsLanguage: ttsLanguage,
ttsVoice: ttsVoice,
ttsAppKey: ttsAppKey,
ttsVolume: ttsVolume,
migrationEnabled: migrationEnabled,
migrationDryRun: migrationDryRun,
stockholmDir: stockholmDir,
@@ -803,7 +917,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
}
if persisted.ServerURL != "" {
config.serverURL = persisted.ServerURL
config.serverURL = handlers.NormalizeServerURL(persisted.ServerURL)
}
if persisted.HTTPServerURL != "" {
@@ -899,6 +1013,30 @@ func applyPersistedMusicServiceCredentials(config *serviceConfig, persisted data
if config.amazonRedirectURI == "" {
config.amazonRedirectURI = persisted.AmazonRedirectURI
}
if config.ttsProvider == "" {
config.ttsProvider = persisted.TTSProvider
}
if config.ttsGoogleAPIKey == "" {
config.ttsGoogleAPIKey = persisted.TTSGoogleAPIKey
}
if config.ttsAppKey == "" {
config.ttsAppKey = persisted.TTSAppKey
}
if config.ttsLanguage == "" {
config.ttsLanguage = persisted.TTSLanguage
}
if config.ttsVoice == "" {
config.ttsVoice = persisted.TTSVoice
}
if config.ttsVolume == 0 {
config.ttsVolume = persisted.TTSVolume
}
}
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
@@ -926,6 +1064,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
}
func initDataStore(dataDir string) *datastore.DataStore {
warnIfDataDirNotWritable(dataDir)
ds := datastore.NewDataStore(dataDir)
if err := ds.Initialize(); err != nil {
log.Printf("Warning: Failed to initialize datastore: %v", err)
@@ -934,6 +1074,48 @@ func initDataStore(dataDir string) *datastore.DataStore {
return ds
}
// warnIfDataDirNotWritable probes the data dir and logs an actionable message
// when the process can't write to it. The common cause is running the
// container as non-root (uid 65532) while a bind-mounted host directory is
// owned by someone else; without this the failure would surface later as a
// cryptic permission error deep in a save. It only warns: the datastore's own
// resilience handles the degraded state.
func warnIfDataDirNotWritable(dataDir string) {
if dataDir == "" {
return
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Printf("WARNING: data dir %s cannot be created: %v", sanitizeLog(dataDir), err)
logDataDirChownHint(dataDir)
return
}
probe := filepath.Join(dataDir, ".write-probe")
if err := os.WriteFile(probe, []byte("ok"), 0o600); err != nil {
log.Printf("WARNING: data dir %s is not writable: %v", sanitizeLog(dataDir), err)
logDataDirChownHint(dataDir)
return
}
_ = os.Remove(probe)
}
// logDataDirChownHint prints the one-time fix for a non-writable bind-mounted
// data dir, using the process's own uid. Skipped where uid is unavailable
// (e.g. Windows), where the hint wouldn't apply.
func logDataDirChownHint(dataDir string) {
uid := os.Getuid()
if uid < 0 {
return
}
log.Printf(" The service runs as uid %d. If you bind-mounted a host directory as the data dir, "+
"make it writable once: chown -R %d:%d %s", uid, uid, uid, sanitizeLog(dataDir))
}
func initCertificateManager(dataDir, hostname string) *certmanager.CertificateManager {
cm := certmanager.NewCertificateManager(filepath.Join(dataDir, "certs"))
@@ -958,9 +1140,88 @@ func startDeviceDiscovery(server *handlers.Server) {
}()
}
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux {
// newEmbeddedWebApp builds the soundtouch-player application for embedding in the
// service router: release metadata from the build vars, the service's public
// ServiceURL (used by Play URL for speaker-fetched stream URLs and shown in the
// UI), a loopback InternalServiceURL for the player's own server-side self-calls
// (the TTS proxy) so they never depend on TLS or the service CA, and device
// state sourced entirely from the service.
//
// The web UI shares the service's discovery rather than running its own (the
// datastore is the single source of truth): ExtraDeviceHosts reads it,
// TriggerDiscovery runs the service sweep on a UI-initiated "discover", and the
// devices-changed hook re-syncs the UI registry whenever the service's
// discovery or a manual add changes the set.
func newEmbeddedWebApp(server *handlers.Server, serverURL, internalURL string, ds *datastore.DataStore) *soundtouchweb.WebApp {
webApp := soundtouchweb.NewWebApp()
webApp.Version = version
webApp.Commit = commit
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(serverURL, "/")
// The player's own server-side calls (the TTS proxy hits
// /api/setup/tts/speak) go to the service's loopback HTTP listener, not the
// public ServiceURL. That avoids the "service doesn't trust its own CA"
// x509 failure entirely: loopback is plain HTTP, so it needs no CA and
// works on HTTP and HTTPS deployments alike — and before the CA is even
// generated. ServiceURL stays the public URL because Play URL bakes it into
// stream URLs the speaker fetches and the UI displays it.
webApp.InternalServiceURL = internalURL
webApp.ExtraDeviceHosts = func() []string {
devices, listErr := ds.ListAllDevices()
if listErr != nil {
log.Printf("web UI: failed to list devices from datastore: %v", listErr)
return nil
}
hosts := make([]string, 0, len(devices))
for i := range devices {
if devices[i].IPAddress != "" {
hosts = append(hosts, devices[i].IPAddress)
}
}
return hosts
}
// UI "discover" runs the service's sweep, not a second mDNS stack.
webApp.TriggerDiscovery = server.DiscoverDevices
// A removal from the player UI cascades to the datastore (the single
// source of truth), so the device does not reappear on the next re-sync.
webApp.RemoveDeviceHook = func(deviceID string) error {
_, err := server.RemoveDeviceByID(deviceID)
return err
}
// Keep the UI registry live as the service discovers or devices are added.
server.SetDevicesChangedHook(func() {
webApp.SeedExtraDevices()
webApp.BroadcastDeviceList()
})
go func() {
// Project the current device set into the UI; the devices-changed hook
// and the service's periodic discovery keep it current from here on.
webApp.SeedExtraDevices()
webApp.BroadcastDeviceList()
}()
return webApp
}
func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp) *chi.Mux {
r := chi.NewRouter()
// CleanPath collapses duplicate slashes ("//bmx/..." -> "/bmx/...") and
// resolves . / .. before routing. Defensive net for the double-slash
// playback bug: even if a misconfigured base URL hands a speaker a "//bmx"
// path, it still reaches the right handler instead of 404ing. Runs first so
// every downstream middleware and the recorder see the cleaned path.
r.Use(middleware.CleanPath)
// TrustedRealIP must run before any handler that reads r.RemoteAddr —
// SnapshotMiddleware captures the request, and several handlers
// (HandleMargePowerOn, etc.) inspect the source IP. The middleware is
@@ -978,13 +1239,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Use(server.RecordMiddleware)
r.Get("/", server.HandleRoot)
r.Get("/admin", server.HandleAdmin)
r.Get("/health", server.HandleHealth)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for
// any inbound from that IP. Used post-migration where the daemon
// caches its swUpdateUrl at boot and the active round-trip can't
// reach it without a reboot.
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
@@ -994,6 +1250,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
})
r.Get("/media/aftertouch-ding.wav", server.HandleDing)
// Synthesized TTS clips (Google Cloud provider). Served before the
// /media/* wildcard so the {id} param route takes precedence.
r.Get("/media/tts/{id}", server.HandleTTSMedia)
r.Get("/media/*", server.HandleMedia())
r.Get("/bmx-icons/*", server.HandleBmxIcons())
r.Get("/ced/*", server.HandleCedStatic())
@@ -1006,6 +1265,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
r.Route("/tunein", func(r chi.Router) {
// Bare service descriptor (the registry's `self` link for TuneIn).
r.Get("/", server.HandleTuneInService)
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
@@ -1026,6 +1287,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
// pkg/service/handlers/static/bmx_services_ustream.json), so speakers
// reach the token + station endpoints at exactly these paths under
// either DNS-interception or URL-flip migration.
r.Get("/core02/svc-bmx-adapter-orion/prod/orion", server.HandleOrionService)
r.Post("/core02/svc-bmx-adapter-orion/prod/orion/token", server.HandleOrionToken)
r.Get("/core02/svc-bmx-adapter-orion/prod/orion/station", server.HandleOrionPlayback)
@@ -1046,6 +1308,7 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Post("/account/{account}/source", server.HandleMargeAddSource)
r.Delete("/account/{account}/source/{sourceID}", server.HandleMargeDeleteSource)
r.Route("/account/{account}", func(r chi.Router) {
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
@@ -1090,6 +1353,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/{device}/group/member", server.HandleMargeDeviceGroupMember)
})
// Speakers POST to /group/ (with trailing slash) when forwarding
// the addGroup payload to Marge during stereo-pair formation --
// see issue #252. Register both forms so chi accepts either.
// Speakers POST to /group/ (with trailing slash) when forwarding
// the addGroup payload to Marge during stereo-pair formation --
// see issue #252. Register both forms so chi accepts either.
@@ -1097,6 +1363,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
// Speakers send DELETE /group/ (no group ID, trailing slash) during
// stereo-pair teardown; master and slave use their own account IDs
// so each deletes its own copy.
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
})
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
@@ -1126,29 +1397,38 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
})
// The /accounts/* group mirrored /streaming/account/* for compatibility, but
// no speaker or app was ever observed using this prefix in the recording
// corpus (the integration tests that exercised it were migrated onto the
// /streaming equivalents). The whole mirror is therefore treated as unused
// and stubbed (HandleUnsupported): it logs + 501s so any real-world use
// surfaces instead of being silently dropped, leaving the prefix a clean
// removal candidate for the #451 refactor.
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Get("/full", server.HandleUnsupported)
r.Get("/sources", server.HandleUnsupported)
r.Get("/devices", server.HandleUnsupported)
r.Post("/devices", server.HandleMargeAddDevice)
r.Post("/devices", server.HandleUnsupported)
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Delete("/devices/{device}", server.HandleUnsupported)
r.Get("/devices/{device}/group", server.HandleUnsupported)
r.Get("/devices/{device}/group/", server.HandleUnsupported)
r.Get("/devices/{device}/group/server", server.HandleUnsupported)
r.Get("/devices/{device}/group/member", server.HandleUnsupported)
r.Post("/group", server.HandleMargeAddGroup)
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/group", server.HandleUnsupported)
r.Post("/group/", server.HandleUnsupported)
r.Post("/group/{groupId}", server.HandleUnsupported)
r.Delete("/group/{groupId}", server.HandleUnsupported)
r.Delete("/group", server.HandleUnsupported)
r.Delete("/group/", server.HandleUnsupported)
r.Get("/devices/{device}/presets", server.HandleUnsupported)
r.Get("/devices/{device}/recents", server.HandleUnsupported)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleUnsupported)
r.Post("/devices/{device}/recents", server.HandleUnsupported)
})
})
@@ -1174,49 +1454,79 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
// app_key validation for the /speaker notification endpoint. Real Bose
// validated the app_key against its cloud; as the cloud replacement we
// accept it (200). A 404 here makes the speaker report "invalid app key"
// (HandleInvalidAppKeyCb) and refuse TTS/URL notifications.
// When an active DNS-path probe is running (POST /setup/health/dns-path-probe),
// a matching probe nonce returns 403 instead so no audio plays.
r.Get("/auth", server.HandleSpeakerAuth)
})
// Management API (admin tier). Registered under both /mgmt (legacy) and
// /api/mgmt (new canonical — issue #451 route-transition step 1) from one
// shared registration so the two paths stay byte-identical; both carry the
// same Basic Auth. The browser OAuth callbacks are externally-pinned
// (provider redirect URIs) and therefore stay at /mgmt only, not aliased.
mountMgmtAuthed := func(r chi.Router) {
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Route("/amazon", func(r chi.Router) {
r.Post("/init", server.HandleMgmtAmazonInit)
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
r.Get("/token", server.HandleMgmtAmazonToken)
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
}
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callbacks — no auth required (provider redirects the
// user's browser here directly). The authorization code is single-use,
// short-lived, and useless without the client_secret.
// short-lived, and useless without the client_secret. Not aliased under
// /api/mgmt (externally-pinned redirect URIs).
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
// All other management endpoints require Basic Auth.
// All other management endpoints require Basic Auth. On the legacy mount
// they also carry the deprecation signal (counts + one-time warning); the
// callbacks above are excluded (externally-pinned, not deprecated).
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Route("/amazon", func(r chi.Router) {
r.Post("/init", server.HandleMgmtAmazonInit)
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
r.Get("/token", server.HandleMgmtAmazonToken)
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Use(server.DeprecatedRouteMiddleware)
mountMgmtAuthed(r)
})
})
r.Route("/setup", func(r chi.Router) {
r.Route("/api/mgmt", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
mountMgmtAuthed(r)
})
})
// Setup / admin API (admin tier). Registered under both /setup (legacy) and
// /api/setup (new canonical) from one shared registration. The Stockholm
// setup-wizard static catch-all is a frontend concern and stays under /setup
// only — /api/setup serves data only.
mountSetupAPI := func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
@@ -1224,6 +1534,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/discovery-status", server.HandleGetDiscoveryStatus)
r.Get("/settings", server.HandleGetSettings)
r.Post("/settings", server.HandleUpdateSettings)
// TTS lives under /setup (LAN-trust, like the rest of the integration
// surface and Play URL), not /mgmt: the API key is already configured
// via /setup/settings, and -web/CLI reach this without mgmt credentials.
r.Post("/tts/speak", server.HandleTTSSpeak)
r.Get("/tts/config", server.HandleTTSConfig)
r.Get("/info/{deviceId}", server.HandleGetDeviceInfo)
r.Get("/summary/{deviceId}", server.HandleGetMigrationSummary)
r.Post("/migrate/{deviceId}", server.HandleMigrateDevice)
@@ -1231,6 +1546,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for any
// inbound from that IP. Used post-migration where the daemon caches its
// swUpdateUrl at boot and the active round-trip can't reach it without a
// reboot.
r.Post("/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
@@ -1260,17 +1581,42 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/health", server.HandleHealthChecks)
r.Post("/health/fix", server.HandleHealthFix)
r.Post("/health/dns-path-probe", server.HandleDNSPathProbe)
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
r.Get("/logs", server.HandleGetLogs)
}
// Serve Stockholm setup wizard pages for paths not matched by the management API.
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
r.Route("/setup", func(r chi.Router) {
// Legacy admin API: same handlers as /api/setup, plus the deprecation
// signal (counts + one-time warning). Scoped to the API routes only — the
// Stockholm wizard catch-all below is frontend, not a deprecated API path.
r.Group(func(r chi.Router) {
r.Use(server.DeprecatedRouteMiddleware)
mountSetupAPI(r)
})
// Serve Stockholm setup wizard pages for paths not matched by the
// management API. The Stockholm frontend has a setup/ directory that must
// be accessible at /setup/*. Frontend-only — not mirrored under /api/setup.
if stockholmHandler != nil {
r.Get("/*", stockholmHandler.HandleStatic)
r.Get("/", stockholmHandler.HandleStatic)
}
})
r.Route("/api/setup", func(r chi.Router) {
mountSetupAPI(r)
})
// Embedded web UI: control API under /api/control and the SPA under /app
// (LAN-trust, like /setup). Additive — nothing here collides with the
// service's own /, /health, or /static. The web app shares the service's
// discovery (nil discovery service here), so it runs no mDNS of its own.
// Skipped when nil, e.g. unit tests that only exercise the service surface.
if webApp != nil {
webApp.MountWeb(r, nil)
}
if stockholmHandler != nil {
stockholmHandler.Mount(r)
}
+6 -3
View File
@@ -13,13 +13,16 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
"github.com/go-chi/chi/v5"
)
func TestPrintRoutes(t *testing.T) {
// Initialize a minimal server to get the router
// Initialize a minimal server to get the router. Pass a web app so the
// snapshot also captures the embedded soundtouch-player surface
// (/api/control + /app); discovery is nil since we only register routes.
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
r := setupRouter(server, nil)
r := setupRouter(server, nil, soundtouchweb.NewWebApp())
var routes []string
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
@@ -128,7 +131,7 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) {
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false)
r := setupRouter(server, nil)
r := setupRouter(server, nil, nil)
ts := httptest.NewServer(r)
defer ts.Close()
@@ -0,0 +1,35 @@
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
DELETE /streaming/account/{account}/group
GET /bmx-icons/*
GET /bmx/tunein/v1/navigate
GET /bmx/tunein/v1/navigate/*
GET /bmx/tunein/v1/playback/episode/{podcastID}
GET /bmx/tunein/v1/playback/episodes/{podcastID}
GET /bmx/tunein/v1/search
GET /bmx/tunein/v1/search/next
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
GET /media/tts/{id}
GET /streaming/account/{account}/device/{device}/group
GET /streaming/account/{account}/device/{device}/group/member
GET /streaming/account/{account}/device/{device}/group/server
GET /streaming/account/{account}/device/{device}/recent
GET /streaming/account/{account}/presets
GET /streaming/device_setting/account/{account}/device/{device}/device_settings
POST /core02/svc-bmx-adapter-orion/prod/orion/token
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
POST /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token
POST /streaming/account/{account}/device/{device}
POST /streaming/account/{account}/device/{device}/presets/{presetNumber}
POST /streaming/account/{account}/group
POST /streaming/account/{account}/group/{groupId}
POST /streaming/device_setting/account/{account}/device/{device}/device_settings
POST /streaming/music/musicprovider/{providerID}/trial/is_eligible
POST /streaming/stats/error
POST /streaming/stats/usage
POST /v1/stapp/{deviceId}
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter
PUT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/*
+130 -17
View File
@@ -1,7 +1,15 @@
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
CONNECT /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
DELETE /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleDeleteDevice-fm
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /api/setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /api/setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
@@ -12,20 +20,76 @@ DELETE /setup/interactions/sessions/{session} handlers.(
DELETE /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
DELETE /streaming/account/{account}/source/{sourceID} handlers.(*Server).HandleMargeDeleteSource-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /admin handlers.(*Server).HandleAdmin-fm
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
GET /api/control/devices/{id}/power-status soundtouchweb.(*WebApp).HandleDevicePowerStatus-fm
GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm
GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm
GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm
GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm
GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
GET /api/control/providers/tunein/navigate/* soundtouchweb.(*WebApp).HandleTuneInNavigate-fm
GET /api/control/providers/tunein/search soundtouchweb.(*WebApp).HandleTuneInSearch-fm
GET /api/control/providers/tunein/search/next soundtouchweb.(*WebApp).HandleTuneInSearchNext-fm
GET /api/control/version soundtouchweb.(*WebApp).HandleAPIVersion-fm
GET /api/control/ws soundtouchweb.(*WebApp).HandleWebSocket-fm
GET /api/mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /api/mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /api/mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
GET /api/mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
GET /api/mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
GET /api/mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
GET /api/mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /api/mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /api/setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /api/setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /api/setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /api/setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /api/setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /api/setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /api/setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /api/setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /api/setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
GET /api/setup/health handlers.(*Server).HandleHealthChecks-fm
GET /api/setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /api/setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /api/setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /api/setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /api/setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /api/setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
GET /api/setup/logs handlers.(*Server).HandleGetLogs-fm
GET /api/setup/settings handlers.(*Server).HandleGetSettings-fm
GET /api/setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /api/setup/tts/config handlers.(*Server).HandleTTSConfig-fm
GET /api/setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /app soundtouchweb.(*WebApp).serveIndex-fm
GET /app/device/* soundtouchweb.(*WebApp).serveIndex-fm
GET /app/devices soundtouchweb.(*WebApp).serveIndex-fm
GET /app/playurl soundtouchweb.(*WebApp).serveIndex-fm
GET /app/radiobrowser soundtouchweb.(*WebApp).serveIndex-fm
GET /app/static/* http.Handler.ServeHTTP-fm
GET /app/tts soundtouchweb.(*WebApp).serveIndex-fm
GET /app/tunein soundtouchweb.(*WebApp).serveIndex-fm
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
GET /bmx/tunein/ handlers.(*Server).HandleTuneInService-fm
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
@@ -34,6 +98,7 @@ GET /bmx/tunein/v1/playback/station/{stationID} handlers.(
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
GET /bmx/tunein/v1/search/next handlers.(*Server).HandleTuneInSearchNext-fm
GET /ced/* handlers.(*Server).HandleCedStatic
GET /core02/svc-bmx-adapter-orion/prod/orion handlers.(*Server).HandleOrionService-fm
GET /core02/svc-bmx-adapter-orion/prod/orion/station handlers.(*Server).HandleOrionPlayback-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
GET /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
@@ -44,6 +109,7 @@ GET /favicon.ico setupRoute
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /media/aftertouch-ding.wav handlers.(*Server).HandleDing-fm
GET /media/tts/{id} handlers.(*Server).HandleTTSMedia-fm
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
@@ -73,6 +139,7 @@ GET /setup/logging-settings handlers.(
GET /setup/logs handlers.(*Server).HandleGetLogs-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/tts/config handlers.(*Server).HandleTTSConfig-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
@@ -94,6 +161,7 @@ GET /streaming/resources/api_versions.xml handlers.(
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/auth handlers.(*Server).HandleSpeakerAuth-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
@@ -102,13 +170,56 @@ OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handler
OPTIONS /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
PATCH /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /accounts/{account}/group handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/ handlers.(*Server).HandleMargeAddGroup-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeModifyGroup-fm
POST /accounts/{account}/devices handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
POST /api/control/devices/{id}/play soundtouchweb.(*WebApp).HandleDevicePlay-fm
POST /api/control/devices/{id}/power soundtouchweb.(*WebApp).HandleDevicePower-fm
POST /api/control/devices/{id}/providers/radiobrowser/play soundtouchweb.(*WebApp).HandlePlayRadioBrowser-fm
POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm
POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm
POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm
POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm
POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm
POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm
POST /api/control/devices/{id}/zone/leave soundtouchweb.(*WebApp).HandleZoneLeave-fm
POST /api/control/devices/{id}/zone/remove/{slaveId} soundtouchweb.(*WebApp).HandleZoneRemove-fm
POST /api/control/discover soundtouchweb.(*WebApp).MountWeb
POST /api/mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
POST /api/mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
POST /api/mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
POST /api/mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
POST /api/mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
POST /api/mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
POST /api/mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /api/mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /api/mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /api/setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
POST /api/setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /api/setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /api/setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /api/setup/health/dns-path-probe handlers.(*Server).HandleDNSPathProbe-fm
POST /api/setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /api/setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /api/setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /api/setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /api/setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /api/setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /api/setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /api/setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /api/setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /api/setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /api/setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /api/setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /api/setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /api/setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /api/setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
@@ -134,6 +245,7 @@ POST /setup/backup/{deviceId} handlers.(
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/health/dns-path-probe handlers.(*Server).HandleDNSPathProbe-fm
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
@@ -148,6 +260,7 @@ POST /setup/test-connection/{deviceId} handlers.(
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
-2
View File
@@ -1,2 +0,0 @@
soundtouch-web
soundtouch-web-test
+49 -2
View File
@@ -16,9 +16,26 @@ services:
- AMAZON_CLIENT_SECRET=mock-amazon-secret
- AMAZON_TOKEN_URL=http://amazon-mock:8080/auth/o2/token
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
- TUNEIN_OPML_URL=http://tunein-mock:8080
- TUNEIN_API_URL=http://tunein-mock:8080
# Start only once every mock is actually listening (the mocks are `go run`,
# so cold compilation can take a while); see depends_on below.
depends_on:
spotify-mock:
condition: service_healthy
amazon-mock:
condition: service_healthy
tunein-mock:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8000/health"]
interval: 3s
timeout: 3s
retries: 30
start_period: 3s
spotify-mock:
image: golang:1.26.3-alpine
image: golang:1.26.4-alpine
container_name: spotify-mock
working_dir: /app
volumes:
@@ -28,9 +45,15 @@ services:
- "8081:8080"
networks:
- soundtouch-test-net
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
interval: 3s
timeout: 3s
retries: 30
start_period: 3s
amazon-mock:
image: golang:1.26.3-alpine
image: golang:1.26.4-alpine
container_name: amazon-mock
working_dir: /app
volumes:
@@ -40,6 +63,30 @@ services:
- "8082:8080"
networks:
- soundtouch-test-net
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
interval: 3s
timeout: 3s
retries: 30
start_period: 3s
tunein-mock:
image: golang:1.26.4-alpine
container_name: tunein-mock
working_dir: /app
volumes:
- .:/app
command: go run ./cmd/mock-tunein/main.go -port 8080
ports:
- "8083:8080"
networks:
- soundtouch-test-net
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/healthz"]
interval: 3s
timeout: 3s
retries: 30
start_period: 3s
networks:
soundtouch-test-net:
+3 -3
View File
@@ -665,7 +665,7 @@ soundtouch --device 192.0.2.100 preset 1
soundtouch interactive
# Web interface
soundtouch-webapp --port 8080
soundtouch-playerapp --port 8080
```
### JavaScript/WASM Usage
@@ -727,10 +727,10 @@ client.startEventStream((event) => {
./soundtouch-linux-amd64 --device IP play
# Web Application (embedded assets)
./soundtouch-webapp-linux-amd64 --port 8080
./soundtouch-playerapp-linux-amd64 --port 8080
# Docker
docker run -p 8080:8080 soundtouch-webapp
docker run -p 8080:8080 soundtouch-playerapp
```
### Development Environment
+1 -1
View File
@@ -35,7 +35,7 @@ layout: hextra-home
>}}
{{< hextra/feature-card
title="Music Browsing"
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-web and soundtouch-cli."
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-player and soundtouch-cli."
icon="speakerphone"
>}}
{{< hextra/feature-card
+6 -6
View File
@@ -46,16 +46,16 @@ selection behave the same as before.
Your six preset buttons work. AfterTouch stores preset bindings locally and serves them
back to the speaker on request. You can also **save new presets** — via the API,
via `soundtouch-cli`, or through the soundtouch-web UI.
via `soundtouch-cli`, or through the soundtouch-player UI.
### ST-10 stereo pairing
**SoundTouch 10 stereo pairs** (and other ST pairing configurations) are supported
end-to-end: creation, management, and playback routing all go through AfterTouch.
### soundtouch-web — browser UI
### soundtouch-player — browser UI
**soundtouch-web** is an early-stage but functional browser UI bundled with AfterTouch.
**soundtouch-player** is an early-stage but functional browser UI bundled with AfterTouch.
It gives you:
- TuneIn and RadioBrowser browsing and playback
@@ -65,7 +65,7 @@ It gives you:
It runs as part of the AfterTouch service — no separate install needed.
![soundtouch-web UI showing Spotify playback, presets, sources, and zone management](/images/blog/soundtouch-web-ui.png)
![soundtouch-player UI showing Spotify playback, presets, sources, and zone management](/images/blog/soundtouch-player-ui.png)
### Automation with soundtouch-cli
@@ -110,9 +110,9 @@ right places to start.
## What's next
The soundtouch-web UI will gain richer preset management — browsing, editing, and
The soundtouch-player UI will gain richer preset management — browsing, editing, and
reordering presets directly from the browser. Longer term, merging
`soundtouch-service` and `soundtouch-web` into a single binary is on the table,
`soundtouch-service` and `soundtouch-player` into a single binary is on the table,
which would simplify deployment to a single process with no extra flags.
This blog will be updated monthly — or whenever something significant ships.
+3 -3
View File
@@ -1,13 +1,13 @@
---
title: "Bose SoundTouch API Coverage Analysis"
---
**Last Updated:** February 2026
**Last Updated:** June 2026 (reconciled against `pkg/client`)
**API Version:** Official Bose SoundTouch Web API v1.0
**Implementation Status:** 100% Official Coverage + Extended Features
**Implementation Status:** Official coverage 20/21 + extended features
## Executive Summary
This Go implementation provides **complete coverage** of the Bose SoundTouch Web API with **100% of official endpoints implemented** (18/19) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
This Go implementation provides near-complete coverage of the Bose SoundTouch Web API with **20 of 21 official endpoints implemented** (the one exception, `/trackInfo`, is documented but non-functional on real hardware) plus **5 additional extended features** not documented in the official API v1.0 but working with real hardware.
### Key Findings
- ✅ **All essential user functionality implemented**
+15 -15
View File
@@ -84,7 +84,7 @@ sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
Name=wlan0
[Network]
Address=192.168.10.1/24
Address=198.51.100.1/24
IPForward=yes
ConfigureWithoutCarrier=yes
DHCP=no
@@ -104,7 +104,7 @@ sudo systemctl mask wpa_supplicant@wlan0
**Verify:**
```bash
ip addr show wlan0
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
# Expected: ONLY inet 198.51.100.1/24 (NO second DHCP IP)
```
---
@@ -151,9 +151,9 @@ sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
sudo tee /etc/dnsmasq.conf << 'EOF'
interface=wlan0
dhcp-range=192.168.10.100,192.168.10.200,24h
dhcp-option=3,192.168.10.1
dhcp-option=6,192.168.10.1
dhcp-range=198.51.100.100,198.51.100.200,24h
dhcp-option=3,198.51.100.1
dhcp-option=6,198.51.100.1
# DNS Upstream: custom server on localhost (adjust port if necessary)
server=127.0.0.1#5353 # Example: custom server on port 5353
@@ -237,7 +237,7 @@ If you cannot see the `Bose-Lab` SSID on your phone:
```bash
sudo nmcli device set wlan0 managed no
```
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `198.51.100.1` and another IP (like `192.0.2.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
```bash
sudo nmcli device set wlan0 managed no
# If the ghost IP is still there, remove it manually:
@@ -259,13 +259,13 @@ If you haven't created a CA yet, follow **Appendix A** first.
# Temporarily make reachable via HTTP for easy download:
cd /etc/my-dns-ca/
python3 -m http.server 8080
# → Reachable at http://192.168.10.1:8080/ca.crt
# → Reachable at http://198.51.100.1:8080/ca.crt
```
### Install on Android
1. Connect phone to `Bose-Lab`
2. Open browser → `http://192.168.10.1:8080/ca.crt`
2. Open browser → `http://198.51.100.1:8080/ca.crt`
3. Download certificate
4. **Settings → Security → Credentials → Install CA Certificate**
5. Select certificate and confirm
@@ -322,7 +322,7 @@ sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
# Traffic of a specific host only (filter by phone IP)
# Read phone IP from dnsmasq.leases beforehand (see below)
sudo tcpdump -i wlan0 -n host 192.168.10.101
sudo tcpdump -i wlan0 -n host 198.51.100.101
```
### Read SNI from TLS Traffic (without decryption)
@@ -351,7 +351,7 @@ Transfer `.pcap` files from the Pi to the PC:
```bash
# From the PC (scp)
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
scp pi@198.51.100.1:/tmp/bose-*.pcap ~/Desktop/
```
**Important Wireshark Filters:**
@@ -607,7 +607,7 @@ You can either configure the macOS system proxy manually or use `mitmproxy`'s au
**Method 1: System Proxy (Manual)**
1. Go to **System Settings → Network → Wi-Fi → Details... → Proxies**.
2. Enable **HTTP Proxy** and **HTTPS Proxy**.
3. Set Server to your Pi's IP (`192.168.10.1`) and Port to `8080`.
3. Set Server to your Pi's IP (`198.51.100.1`) and Port to `8080`.
4. Click **OK** and **Apply**.
**Method 2: mitmproxy Local Redirect (Automatic)**
@@ -667,7 +667,7 @@ If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA t
If the **Transparent AP** setup (Steps 16) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
### 1. How it works
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `198.51.100.1:8080`.
* **Pros:** No complex `nftables` or NAT rules required.
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
@@ -683,7 +683,7 @@ mitmproxy --listen-port 8080
1. Go to **Settings → Wi-Fi → Bose-Lab**.
2. Select **Modify Network** (or the "i" icon).
3. Set **Proxy** to **Manual**.
4. **Proxy hostname:** `192.168.10.1`
4. **Proxy hostname:** `198.51.100.1`
5. **Proxy port:** `8080`
6. Save and try to browse a site.
@@ -706,7 +706,7 @@ go get github.com/google/gopacket
go run scripts/extract-ws.go your_capture.pcap [filter_ip]
# Example: Filter for a specific speaker's IP in WebSocket messages
go run scripts/extract-ws.go capture.pcap 192.168.100.1
go run scripts/extract-ws.go capture.pcap 203.0.113.1
```
### 2. Manual Extraction with tshark
@@ -889,5 +889,5 @@ pgrep -a tcpdump
dig @127.0.0.1 -p 5353 global.api.bose.io
# Check network connectivity from the phone (from the Pi)
ping 192.168.10.101 # Phone IP from dnsmasq.leases
ping 198.51.100.101 # Phone IP from dnsmasq.leases
```
+40 -27
View File
@@ -3,10 +3,23 @@ title: "SoundTouch supportedURLs Endpoint Analysis"
---
This document provides a comprehensive analysis of the `/supportedURLs` endpoint response from real Bose SoundTouch devices and compares it with our current implementation.
> **Reconciliation note (June 2026).** The categorised lists below had drifted
> from `pkg/client`. Verified against the code, these are **implemented** and have
> been re-marked (some were wrongly under "Not Yet Implemented", and a few were
> listed twice): the music-service set (`setMusicServiceAccount`,
> `setMusicServiceOAuthAccount`, `removeMusicServiceAccount`, `serviceAvailability`),
> presets (`storePreset`, `removePreset`), stations (`searchStation`, `addStation`,
> `removeStation`), `navigate`, the native stereo-pair group set (`getGroup`,
> `addGroup`, `removeGroup`, `updateGroup`), `speaker`, `playNotification`,
> `requestToken`, `notification`. Still **not** implemented (confirmed absent from
> `pkg/client`): `search`, `standby`, `powerManagement`, `lowPowerStandby`,
> `language`, `listMediaServers`, `bluetoothInfo`, `userPlayControl`, and the
> wireless / bluetooth-pairing / software-update / source-shortcut families.
## Discovery Summary
**Test Devices:**
- Device 1: `192.0.2.11:8090` (deviceID: `08DF1F0BA325`)
- Device 1: `192.0.2.11:8090` (deviceID: `AABBCCDDEE01`)
- Device 2: `192.0.2.10:8090` (deviceID: `AABBCCDDEEFF`)
**Key Findings:**
@@ -61,18 +74,18 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
- `/audioproducttonecontrols` - Advanced tone controls (capability-dependent)
- `/audioproductlevelcontrols` - Speaker level controls (capability-dependent)
**System Info (3/3):**
- `/trackInfo` - Track information
- `/bluetoothInfo` - Bluetooth information
- `/recents` - Recently played content
**System Info (1/3):**
- `/recents` - Recently played content ✅
- `/trackInfo` - Track information ❌ non-functional on real devices (use `/now_playing`)
- `/bluetoothInfo` - Bluetooth information ❌ not implemented in `pkg/client`
### 🔶 Partially Implemented/Different Approach
### ✅ Stereo-Pair Group Management (native)
**Zone Management:**
- `/addGroup` ⚠️ - We use `/setZone` for group management
- `/removeGroup` ⚠️ - We use `/setZone` for group management
- `/getGroup` ⚠️ - We use `/getZone` for group information
- `/updateGroup` ⚠️ - We use `/setZone` for group updates
Implemented natively in `pkg/client` (in addition to the `/setZone` multiroom path):
- `/addGroup` - `AddGroup()`
- `/removeGroup` - `RemoveGroup()`
- `/getGroup` - `GetGroup()`
- `/updateGroup` - `UpdateGroup()`
### ❌ Not Yet Implemented (High Priority)
@@ -91,22 +104,22 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
- `/selectLastSoundTouchSource` - Select last SoundTouch source
- `/selectLocalSource` - Select local source
**Music Services Integration:**
- `/setMusicServiceAccount` - Configure music service account
- `/setMusicServiceOAuthAccount` - OAuth account setup
- `/removeMusicServiceAccount` - Remove music service account
- `/serviceAvailability` - Check service availability
**Music Services Integration:** ✅ implemented (moved out of this list)
- ~~`/setMusicServiceAccount`~~`SetMusicServiceAccount()`
- ~~`/setMusicServiceOAuthAccount`~~`SetMusicServiceOAuthAccount()`
- ~~`/removeMusicServiceAccount`~~`RemoveMusicServiceAccount()`
- ~~`/serviceAvailability`~~`GetServiceAvailability()`
**Enhanced Presets:**
- `/storePreset` - Store new preset
- `/removePreset` - Remove existing preset
- ~~`/storePreset`~~`StorePreset()` (also listed under Fully Implemented)
- ~~`/removePreset`~~`RemovePreset()`
- `/bookmark` - Bookmark current content
- `/userRating` - User rating for content
**Station/Radio Management:**
- `/searchStation` - Search for stations
- `/addStation` - Add station to favorites
- `/removeStation` - Remove station from favorites
- ~~`/searchStation`~~`SearchStation()`
- ~~`/addStation`~~`AddStation()`
- ~~`/removeStation`~~`RemoveStation()`
- `/genreStations` - Browse stations by genre
- `/stationInfo` - Station information
@@ -119,7 +132,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
- `/systemtimeout` - System timeout settings
- `/powersaving` - Power saving configuration
- `/language` - Language settings
- `/speaker` - Speaker configuration
- ~~`/speaker`~~`PlayTTS()` / `PlayURL()` (TTS & URL notifications; not "speaker configuration")
**Network & Connectivity:**
- `/performWirelessSiteSurvey` - WiFi site survey
@@ -133,7 +146,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
**Content Discovery:**
- `/search` - Content search
- `/navigate` - Content navigation
- ~~`/navigate`~~`Navigate()`
- `/listMediaServers` - List available media servers
### ❌ Not Yet Implemented (Low Priority)
@@ -156,9 +169,9 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
**System Utilities:**
- `/userActivity` - User activity tracking
- `/requestToken` - Token management
- `/notification` - Notification management
- `/playNotification` - Play notification sound
- ~~`/requestToken`~~`RequestToken()`
- ~~`/notification`~~`NotifySourcesUpdated()`
- ~~`/playNotification`~~`PlayNotification()`
- `/introspect` - System introspection
- `/test` - System test interface
@@ -229,7 +242,7 @@ This document provides a comprehensive analysis of the `/supportedURLs` endpoint
**Example Response Structure:**
```xml
<?xml version="1.0" encoding="UTF-8" ?>
<supportedURLs deviceID="08DF1F0BA325">
<supportedURLs deviceID="AABBCCDDEE01">
<URL location="/info" />
<URL location="/capabilities" />
<!-- ... 101 additional endpoints ... -->
@@ -198,7 +198,7 @@ The web UI is already fully responsive — it has Bootstrap grid columns, `@medi
### Priority 2 — RadioBrowser as a first-class provider
AfterTouch can proxy and play any stream URL, but there is no built-in station search. OpenCloudTouch's RadioBrowser integration is the reference. Tasks:
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-web` web UI as a browsable/searchable source.
- Wire the [RadioBrowser API](https://www.radio-browser.info/) into the `soundtouch-player` web UI as a browsable/searchable source.
- Make discovered stations directly presetable to hardware buttons.
- This is the most common replacement for TuneIn for users who listened to internet radio via presets.
@@ -242,7 +242,7 @@ These exist in soundcork but are deliberate architectural choices in AfterTouch,
| Area | soundcork | AfterTouch |
|--------------------------|---------------------------------------|-----------------------------------------------------------|
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-web` component (Go + plain HTML/JS) |
| Web UI | FastAPI + Jinja2 miniapp and admin UI | Separate `soundtouch-player` component (Go + plain HTML/JS) |
| Direct device management | SSH/SCP access into speakers | HTTP API only; no SSH |
| Device discovery client | Python `upnpclient` library | mDNS + UPnP in Go, with dedicated DNS interception server |
| Token delivery | Push (ZeroConf priming to port 8200) | Pull (device calls back to fetch) |
@@ -7,11 +7,11 @@ sidebar:
## Overview
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-web UI, the CLI, or the Go library.
SoundTouch devices support 6 preset slots that can store your favorite content for instant access. This guide shows you how to manage presets using the soundtouch-player UI, the CLI, or the Go library.
## Via soundtouch-web (browser UI)
## Via soundtouch-player (browser UI)
**soundtouch-web** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
**soundtouch-player** (default port **8080**) is the easiest way to manage presets without the command line. Two save paths are available whenever content is playing:
### ★ Star button — save from Now Playing
@@ -3,13 +3,30 @@ title: "Unimplemented SoundTouch API Endpoints"
sidebar:
exclude: true
---
**Last Updated:** January 2026
**Last Updated:** June 2026 (reconciled against `pkg/client`)
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
**Current Implementation:** 35 endpoints (including preset & navigation management discovered via SoundTouch Plus Wiki)
**Current Implementation:** ~41 endpoints in `pkg/client` (see reconciliation note)
**Wiki Documentation:** 87 endpoints
**Implementation Gap:** 52 endpoints
**Implementation Gap:** ~46 endpoints
This document provides comprehensive information about SoundTouch API endpoints documented in the community wiki but not yet implemented in this Go library. All examples are based on real device responses and extensive community testing.
This document covers SoundTouch **device** WebServices API endpoints (the
speaker's local `:8090` API consumed by `pkg/client`) documented in the community
wiki but not yet implemented. It is **not** about the cloud-service router
(`cmd/soundtouch-service`); for that surface see the contract checklist
`tests/integration/http-client/COVERAGE.md`. Examples are based on real device
responses and community testing.
> **Reconciliation note (June 2026).** Verified against `pkg/client`. Since the
> last update these are **now implemented** and have been re-marked below:
> `setMusicServiceAccount` / `removeMusicServiceAccount` (`SetMusicServiceAccount`,
> `RemoveMusicServiceAccount`) and the full stereo-pair group set
> `getGroup` / `addGroup` / `removeGroup` / `updateGroup`
> (`GetGroup`, `AddGroup`, `RemoveGroup`, `UpdateGroup`). The priority-matrix
> counts further down are historical and have not all been recomputed; trust the
> per-endpoint ✅ markers over the section totals. Endpoints still listed as
> candidates (e.g. `/search`, `/standby`, `/powerManagement`, `/bluetoothInfo`,
> `/language`, `/listMediaServers`) were confirmed absent from `pkg/client`
> (some appear only in test fixtures).
---
@@ -69,12 +86,15 @@ Specialized hardware-specific features.
- CLI command: `preset select --slot <1-6>`
- Alternative: Direct key commands (`SendKey("PRESET_1")` etc.)
### Music Service Management
### ~~Music Service Management~~ ✅ **IMPLEMENTED**
Critical for streaming service integration.
#### POST /setMusicServiceAccount 🔥 **CRITICAL**
#### ~~POST /setMusicServiceAccount~~ ✅ **IMPLEMENTED**
Adds a music service account to the sources list.
**Status:** **COMPLETE** - `pkg/client` exposes `SetMusicServiceAccount(...)`
(and `SetMusicServiceOAuthAccount(...)` for OAuth sources like Spotify/Amazon).
**Request Examples:**
Pandora Service:
@@ -111,9 +131,11 @@ NAS Music Library:
- Note the `/0` suffix for STORED_MUSIC user names
- Spotify requires PREMIUM account for most operations
#### POST /removeMusicServiceAccount 🔥 **CRITICAL**
#### ~~POST /removeMusicServiceAccount~~ ✅ **IMPLEMENTED**
Removes an existing music service account.
**Status:** **COMPLETE** - `pkg/client` exposes `RemoveMusicServiceAccount(...)`.
**Request Examples:**
Remove Pandora:
@@ -630,9 +652,12 @@ Selects LOCAL source (only way to select LOCAL on some devices).
<status>/selectLocalSource</status>
```
### Group Management (ST-10 Stereo Pairs Only)
### ~~Group Management (ST-10 Stereo Pairs Only)~~ ✅ **IMPLEMENTED**
#### GET /getGroup 📊 **MEDIUM**
**Status:** **COMPLETE** - the full stereo-pair set is implemented in `pkg/client`:
`GetGroup()`, `AddGroup()`, `RemoveGroup()`, `UpdateGroup()`.
#### ~~GET /getGroup~~ ✅ **IMPLEMENTED**
Gets current stereo pair configuration.
**Response Example (paired):**
@@ -662,7 +687,7 @@ Gets current stereo pair configuration.
<group />
```
#### POST /addGroup 📊 **MEDIUM**
#### ~~POST /addGroup~~ ✅ **IMPLEMENTED**
Creates new stereo pair group.
**Request Example:**
@@ -688,7 +713,7 @@ Creates new stereo pair group.
**Response:** Same as GET /getGroup
**WebSocket Event:** `groupUpdated` sent to both devices
#### GET /removeGroup 📊 **MEDIUM**
#### ~~GET /removeGroup~~ ✅ **IMPLEMENTED**
Removes existing stereo pair group.
**Response:**
@@ -698,7 +723,7 @@ Removes existing stereo pair group.
**WebSocket Event:** `groupUpdated` sent to both devices
#### POST /updateGroup 📊 **MEDIUM**
#### ~~POST /updateGroup~~ ✅ **IMPLEMENTED**
Updates stereo pair group name.
**Request Example:**
@@ -982,8 +1007,8 @@ func TestDeviceCompatibility(t *testing.T) {
### Phase 1: Essential Features (4 weeks)
1. ✅ **Preset Management**: ~~`storePreset`, `removePreset`, `selectPreset`~~ (IMPLEMENTED)
2. **Music Services**: `setMusicServiceAccount`, `removeMusicServiceAccount`
3. ✅ **Content Discovery**: ~~`navigate`, `search`~~ (IMPLEMENTED), `recents`
2. **Music Services**: ~~`setMusicServiceAccount`, `removeMusicServiceAccount`~~ (IMPLEMENTED)
3. ✅ **Content Discovery**: ~~`navigate`~~ (IMPLEMENTED), `search`, `recents`
4. ✅ **Station Management**: ~~`searchStation`, `addStation`, `removeStation`~~ (IMPLEMENTED)
5. **Enhanced Controls**: `userPlayControl`, `userRating`
@@ -996,7 +1021,7 @@ func TestDeviceCompatibility(t *testing.T) {
### Phase 3: Advanced Features (3 weeks)
1. **Bluetooth**: `enterBluetoothPairing`, `clearBluetoothPaired`
2. **Software Updates**: `swUpdateCheck`, `swUpdateQuery`
3. **Stereo Pairs**: `getGroup`, `addGroup`, `removeGroup`, `updateGroup`
3. **Stereo Pairs**: ~~`getGroup`, `addGroup`, `removeGroup`, `updateGroup`~~ (IMPLEMENTED)
4. **Source Shortcuts**: `selectLastSource`, `selectLastSoundTouchSource`
### Phase 4: Specialized Features (2 weeks)
@@ -22,7 +22,7 @@ The current system uses multiple data collection methods to build a complete dev
Name string // From UPnP friendlyName
Host string // IP address
Port int // Usually 8090
ModelID string // From UPnP modelName
ModelID string // From UPnP modelName
SerialNo string // MAC address from UPnP
UPnPLocation string // Device description URL
UPnPUSN string // Unique service name
@@ -66,7 +66,7 @@ The current system uses multiple data collection methods to build a complete dev
```mermaid
sequenceDiagram
participant Service as SoundTouch Service
participant UPnP as UPnP Discovery
participant UPnP as UPnP Discovery
participant mDNS as mDNS Discovery
participant Device as SoundTouch Device
participant DataStore as Data Store
@@ -76,28 +76,28 @@ sequenceDiagram
Service->>UPnP: Start SSDP Discovery
Service->>mDNS: Start mDNS Discovery
UPnP->>UPnP: Send M-SEARCH multicast
Device->>UPnP: Respond with location URL
UPnP->>Device: Fetch device description XML
Device->>UPnP: Return basic device info
mDNS->>mDNS: Query _soundtouch._tcp
Device->>mDNS: Respond with service info
Service->>Service: Merge discovery results
Service->>Device: GET /info (enrich data)
Device->>Service: Return detailed device info
Service->>DataStore: Store discovered device
Note over User,DataStore: User Registration
User->>Service: POST /account/{id}/devices
Note right of User: deviceId + user-friendly name
Service->>DataStore: Link device to account
Note over Service,DataStore: Migration Process
Service->>Device: GET /info (device identification)
Device->>Service: Return device details
Device->>Service: Return device details
Service->>Service: Build migration summary
Service->>Device: Apply configuration changes
```
@@ -116,7 +116,7 @@ The system has distinct phases where device information is collected and enhance
**Endpoint**: `POST /streaming/account/{accountId}/devices`
**Request Format**:
```xml
<device deviceid="08DF1F0BA325">
<device deviceid="AABBCCDDEE0A">
<name>Living Room Speaker</name>
</device>
```
@@ -199,25 +199,25 @@ The `/power_on` endpoint receives comprehensive device data that could replace m
### Data Completeness Comparison
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|------------|----------------|-------------|----------------|
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
| Data Field | Current `/info` | `/power_on` | Gap Assessment |
|-----------------------|----------------------|--------------------|-------------------|
| **Device ID** | ✅ UUID format | ✅ MAC format | Different format |
| **Device Name** | ✅ Internal name | ❌ Missing | **Critical Gap** |
| **Device Type** | ✅ Model string | ✅ Product code | ✅ Available |
| **Account ID** | ✅ marge UUID | ❌ Missing | **Critical Gap** |
| **Service URL** | ✅ marge URL | ❌ Missing | **Important Gap** |
| **Firmware Version** | ✅ Full version | ✅ Full version | ✅ Available |
| **Serial Numbers** | ✅ Component serials | ✅ Device + Product | ✅ Available |
| **MAC Addresses** | ✅ Interface-specific | ✅ Multiple MACs | ✅ Enhanced |
| **IP Address** | ✅ Interface IPs | ✅ Current IP | ✅ Available |
| **Network Status** | ❌ Basic | ✅ Rich diagnostics | ✅ **Enhanced** |
| **Regional Settings** | ✅ Country/Region | ❌ Missing | **Important Gap** |
### Enhancement Benefits
#### 1. Network Independence
- ✅ Works across internet/WAN connections
- ✅ No multicast/broadcast requirements
- ✅ No multicast/broadcast requirements
- ✅ Firewall/NAT friendly
- ✅ Supports remote device management
@@ -246,21 +246,21 @@ func (s *Server) HandleMargePowerOn(w http.ResponseWriter, r *http.Request) {
// Fallback to existing discovery
return s.fallbackToDiscovery(r.RemoteAddr)
}
// Extract device information
deviceMAC := powerOnData.Device.ID
deviceIP := powerOnData.DiagnosticData.DeviceLandscape.IPAddress
// Lookup existing device data
deviceInfo := s.lookupDeviceByMAC(deviceMAC)
if deviceInfo == nil {
// New device - trigger registration flow
deviceInfo = s.createDeviceFromPowerOn(powerOnData)
}
// Update with power_on data
s.updateDeviceFromPowerOn(deviceInfo, powerOnData)
// Determine response actions
response := s.buildPowerOnResponse(deviceInfo)
s.sendResponse(w, response)
@@ -280,7 +280,7 @@ Address missing data through complementary mechanisms:
```mermaid
sequenceDiagram
participant Device as SoundTouch Device
participant Service as SoundTouch Service
participant Service as SoundTouch Service
participant DataStore as Data Store
participant User as User/App
@@ -293,7 +293,7 @@ sequenceDiagram
alt Device Unknown
Service->>DataStore: Create device record
Service->>User: Notify new device found
else Device Known
else Device Known
Service->>DataStore: Update device status
end
Service->>Device: Configuration response
@@ -360,11 +360,11 @@ type Migration struct {
### Immediate Actions (Phase 1)
1. **Enhance `/power_on` handler** to extract and store comprehensive device data
2. **Implement device lookup by MAC address** as primary identification method
2. **Implement device lookup by MAC address** as primary identification method
3. **Create hybrid discovery system** using both `/power_on` and existing methods
4. **Add network-independent device management** capabilities
### Medium-term Improvements (Phase 2)
### Medium-term Improvements (Phase 2)
1. **Implement account-device MAC mapping** for automatic association
2. **Add IP geolocation** for regional settings inference
3. **Create device registration UI** optimized for `/power_on` discovered devices
@@ -372,7 +372,7 @@ type Migration struct {
### Long-term Enhancements (Phase 3)
1. **Request firmware enhancement** to include missing data in `/power_on`
2. **Implement real-time device monitoring** via `/power_on` events
2. **Implement real-time device monitoring** via `/power_on` events
3. **Create centralized device management** independent of network topology
4. **Add predictive migration** based on device status patterns
@@ -387,8 +387,8 @@ type Migration struct {
The `/power_on` endpoint provides a significant opportunity to reduce network dependencies while enhancing device management capabilities. By implementing a hybrid approach that leverages `/power_on` data for primary device identification and status updates while maintaining existing registration workflows for user-controlled metadata, the system can achieve:
- **Network independence** for core device management
- **Enhanced real-time capabilities** through device-initiated communication
- **Enhanced real-time capabilities** through device-initiated communication
- **Improved scalability** across diverse network topologies
- **Better user experience** with automatic device discovery and status updates
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
The proposed implementation strategy provides a clear path to achieve these benefits while maintaining system reliability and user workflow compatibility.
@@ -1,9 +1,9 @@
---
title: "soundtouch-web: remaining features"
title: "soundtouch-player: remaining features"
sidebar:
exclude: true
---
Four features complete the parity gap between soundtouch-web and the Stockholm
Four features complete the parity gap between soundtouch-player and the Stockholm
app's local-control functionality. Everything else in Stockholm (OAuth flows,
setup wizard, service account linking, onboarding, analytics) is cloud
infrastructure that is either shut down or already handled by soundtouch-service.
@@ -48,7 +48,7 @@ func (c *Client) Seek(positionSeconds int) error {
> **Note:** This section is about the speaker's **built-in** `/favorites` API —
> a separate concept from the 6 preset slots. Preset-slot saving (★ star /
> **+** button) is already shipped; the native Favorites API is not yet
> surfaced in soundtouch-web.
> surfaced in soundtouch-player.
Mark or unmark the currently playing track as a device favourite directly from
the Now Playing card. Unlike presets (maximum 6, numbered slots), the device
@@ -103,7 +103,7 @@ rename and network/firmware info.
## 4. Render stereo pairs as a single device
Today soundtouch-web shows the two halves of a stereo pair (formed via
Today soundtouch-player shows the two halves of a stereo pair (formed via
`/addGroup` — see issue #252) as independent entries in the device list. The
Bose app collapsed a paired ST10 set into one "L+R" entry; restoring that
presentation closes the perception gap BirdyBA flagged at
@@ -139,7 +139,7 @@ end-to-end — `pkg/client` group endpoints + `cmd/soundtouch-cli/cmd_group.go`,
covered by tests in `cmd/soundtouch-cli/cmd_group_test.go` and exercisable
against the fake speaker's group routes
(`pkg/service/testing/fakespeaker/fakespeaker.go`). This task is purely about
presentation in soundtouch-web's device list — no protocol work required.
presentation in soundtouch-player's device list — no protocol work required.
---
@@ -0,0 +1,641 @@
---
title: "API Route Layout and Refactoring Plan"
---
> **Tracking issue:** [#451 "Merge soundtouch-player into soundtouch-service"](https://github.com/gesellix/Bose-SoundTouch/issues/451).
> This document is the architectural reference for the staged API refactoring
> that precedes (and enables) that merge.
## Why this exists
`soundtouch-service` and `soundtouch-player` are two binaries with two routers.
We want to:
1. Restructure our own routes into a layout that can stay stable.
2. Eventually fold `soundtouch-player` into `soundtouch-service` (one binary).
3. Stop leaking frontend (SPA) routes into the backend API.
4. Make **cloud / remote-host a first-class, clean deployment**, not just LAN /
on-device. This is a primary motivation: we consolidate the API *in a way
that* closes the trust and auth gaps a public deployment exposes, rather than
just merging two binaries. Enforced auth is therefore a real requirement, not
an afterthought.
Before moving anything, every route has to be classified by **whether we are
free to move it**, and that depends on **who the client is**. A route the
speaker firmware calls is frozen forever; an internal admin route is ours to
reshape.
## Classification criteria
Classify by client audience, then by what pins the path:
| Category | Client | Free to move? |
|------------------------------------|-----------------------------------|----------------------------------------------------------------------------------------------------------------|
| **(1a) Frozen, firmware-pinned** | Speaker firmware | No, ever. The path is hardcoded in the speaker (or relative to a base it fetches from us). |
| **(1b) Frozen, externally-pinned** | OAuth providers (Spotify/Amazon) | Only with provider re-registration + device re-priming. Treat as frozen unless that cost is paid deliberately. |
| **(2) Service-internal** | The admin/setup UI | Yes, freely. These are ours. |
| **(3) Web/control** | The control UI (soundtouch-player) | Yes, freely. |
| **(4) Frontend (SPA)** | Browser, client-side routing | Should not be enumerated in the backend at all (see `/app/*` below). |
| **(Infra)** | Humans, monitoring, the SPA shell | Conventionally stable; collision-prone at merge time. |
Two refinements that matter in practice:
- **"Must stay" is not one thing.** (1a) is immovable; (1b) is movable but
coordinated. Do not lump OAuth callbacks in with firmware paths.
- **The merge-overlap bucket is smaller than it looks.** Verified against the
two routers, only **`/` is a true collision** (service `HandleRoot` vs the web
app's `serveIndex`); resolve it with a small **landing page** at `/` that lets
the user pick Admin/Setup (service) or the App (web). **`/health` is a merge,
not a clash** (both define it; standardise on the service's richer body, which
carries version + timestamp, and confirm nothing depends on the web's
`{"status":"ok","version"}` shape). **`/ws` and `/static/*` do not collide at
all** — the service registers neither, so bringing the web's in is purely
additive. TuneIn is **not** in this bucket either: `/bmx/tunein/*` (speaker <->
BMX integration, frozen) and `/api/tunein/*` (the player's generalized radio
search/play, ours to change) are two different layers.
**Resolve overlaps structurally, before merging, not behind a flag.** A
conditional "only register the web routes when opt-in is on" does not fix a
collision — it just hides it while the flag is off, and the double-registration
returns when it's on. Do not rely on chi to detect or warn about it. Clean up
`/` (and the `/health` merge) up front so the merged router is unambiguous
regardless of the flag. The opt-in (below) exists only to let people optionally
run the merged variant and give feedback, not as a collision guard.
## What pins the frozen routes (evidence)
- The speaker fetches BMX content, marge/streaming data, software updates, and
CED config from hostnames it has hardcoded (or from a base URL we hand it).
`/ced/*` mirrors `downloads.bose.com/ced/soundtouch/...`; `/bmx`, `/core02`,
`/streaming`, `/accounts`, `/customer`, `/oauth`, `/v1` mirror the Bose cloud
contract.
- Persisted device data embeds absolute service URLs. Presets store
`LOCAL_INTERNET_RADIO`/Orion locations like
`https://.../core02/svc-bmx-adapter-orion/prod/orion/station?data=...`, and
the BMX registry advertises `{MEDIA_SERVER}/media` and `/bmx-icons`. So
`/media`, `/bmx-icons`, `/custom`, and `/core02` are effectively part of the
firmware-facing contract: a speaker that stored a preset will replay that
exact URL later. They cannot move without rewriting persisted state on every
device.
## Service routes (`soundtouch-service`)
Grouped by prefix. The authoritative enumerated list is the router golden file
`cmd/soundtouch-service/testdata/router_routes.txt`.
| Prefix | Category | Client | Movable? |
|----------------------------------------------------------------------------------------------------|-----------------------|---------------------------------------|------------------------------------------|
| `/streaming/*` | (1a) frozen | Speaker (marge / streaming.bose.com) | No |
| `/accounts/*` | (1a) frozen | Speaker (marge, alternate paths) | No |
| `/customer/account/*` | (1a) frozen | Speaker | No |
| `/bmx/*` (registry + tunein) | (1a) frozen | Speaker (BMX) | No |
| `/core02/svc-bmx-adapter-*` (Orion, SiriusXM) | (1a) frozen | Speaker (BMX adapters) | No |
| `/oauth/*/token`<br>`/oauth/*/token/cs`<br>`/oauth/*/token/cs1`<br>`/oauth/*/token/cs3` | (1a) frozen | Speaker (music tokens) | No |
| `/custom/v1/playback/*` | (1a) frozen | Speaker (LOCAL_INTERNET_RADIO / ding) | No |
| `/bmx-icons/*`<br>`/media/*`<br>`/media/aftertouch-ding.wav`<br>`/media/tts/*` | (1a) frozen | Speaker (advertised base) | No |
| `/streaming/resources/api_versions.xml`<br>`/streaming/software/update/*`<br>`/updates/soundtouch` | (1a) frozen | Speaker (SW update) | No |
| `/v1/auth`<br>`/v1/blacklist/*`<br>`/v1/scmudc/*`<br>`/v1/stapp/*` | (1a) frozen | Speaker | No |
| `/alexa/certificate` | (1a) frozen | Speaker / AWS | No |
| `/ced/*` | (1a) frozen | Speaker (mirrors downloads.bose.com) | No |
| `/mgmt/amazon/callback`<br>`/mgmt/spotify/callback` | (1b) frozen, external | OAuth providers | Only with re-registration |
| `/setup/*` (~40 routes) | (2) service-internal | Admin UI | Yes |
| `/mgmt/*` (except the callbacks above) | (2) service-internal | Admin UI | Yes |
| `/web/*` (`HandleWeb`) | (4) frontend | Browser (admin SPA) | Yes; already the clean catch-all pattern |
| `/`<br>`/docs/*`<br>`/favicon.ico`<br>`/health` | (Infra) | Humans / monitoring | Keep stable by convention |
## Web routes (`soundtouch-player`)
Defined in `pkg/service/soundtouchweb/mount.go`. Not currently mounted inside
the service; it is a separate binary.
| Group | Category | Note |
|------------------------------------------------------------------------------------------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/api/*` (devices, control, tunein, zone, radiobrowser, play-url, device-speak) | (3) web/control | Freely restructurable |
| `/health`<br>`/static/*`<br>`/ws` | (Infra) | `/health` is a merge (standardise on the service's body); `/static/*` and `/ws` are additive (the service registers neither) |
| `/`<br>`/device/*`<br>`/devices`<br>`/playurl`<br>`/radiobrowser`<br>`/tts`<br>`/tunein` | (4) frontend | `/` is the one true collision (-> landing page); the rest move under `/app/*`. The anti-pattern: each SPA route enumerated in the backend, all serving `index.html` |
## Deployment scenarios, reachability, and trust boundaries
The client-audience axis tells you *who* calls a route. The deployment tells you
whether that caller can actually reach it and whether the surrounding network
can be trusted. AfterTouch runs in materially different places, and that decides
which routes are even *meaningful* and what the trust boundary is.
### Actors (the original Bose model)
The original Bose architecture had three actors, and our route surface still
reflects all three:
| Actor | Where | Role |
|---------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|
| Speaker | Local (the device) | Calls the cloud for its data-plane (`/full`, presets, sources, software update, tokens) and is provisioned by the app. |
| App | Local (phone / desktop), **in-between** | Creates the account, adds a speaker to an account, and teaches the speaker its cloud/marge credentials. **Authenticates itself** to the cloud. |
| Cloud | External / public (what AfterTouch replaces) | Serves the speaker data-plane and the app's account/provisioning calls. |
Two things matter for our design:
- **The app is deployment-agnostic.** It does not care whether the cloud (our
service) runs locally or in a datacenter; it talks to whatever cloud endpoint
it is pointed at. So the **deployment modes below are about where the *cloud*
role runs**, orthogonal to the app actor.
- **AfterTouch's own tooling currently plays the app's role.** Account creation
and "teach the speaker its marge account" are done by our migration tooling
(today via the speaker's local WebSocket `setMargeAccount`), i.e. we are the
provisioning agent. But the app-facing *cloud* endpoints still exist in the
surface (account create/login, add device, profile, password, groups), and a
real app pointed at us would use them. They are part of the frozen contract,
but their caller and trust story differ from the speaker's data-plane (see
below).
### Deployment topologies (where the cloud role runs)
This is descriptive (where it runs), distinct from the `deployment-mode`
*parameter* below (the security posture). They correlate but are kept separate so
an operator is not locked into one because of the other.
| Topology | Where | Reaches speakers directly? | Speaker reaches it? |
|---------------------|-------------------------------------------|----------------------------|-------------------------------------------|
| On-device | On the speaker itself | Itself only | Yes (loopback / LAN) |
| LAN host | Raspberry Pi / Docker on the home network | Yes (same LAN) | Yes |
| Cloud / remote host | External host, not on the speaker LAN | No | Yes (speaker calls out over the internet) |
### Two planes: speaker-direct vs data-plane
Routes fall into two reachability planes that behave very differently across
deployments:
- **Speaker-direct (control plane):** the service opens a connection *to* the
speaker's local API (`:8090`) right now. Discovery, migration, reboot,
test-connection, peer-probe, and the entire `soundtouch-player`
control/zone/volume/key/TTS-to-speaker surface. These only work where the host
shares the LAN with the speaker. **In a cloud deployment they are dead weight**,
and any UI that shows them is misleading.
- **Data-plane (cloud replacement):** something calls the *service*, which works
in every deployment because the caller reaches in. Two callers live here:
- **Speaker-polled:** the speaker fetches its own data (`/full`, sources,
presets, recents, provider/device settings, software update, streaming
token, stats). No user auth; the speaker is identified by account/device.
- **App / provisioning-called:** the app (or, today, our own tooling acting as
the app) creates accounts, logs in, adds/updates/removes devices, edits the
profile/password, and manages groups. In the original model the app
**authenticates itself** here, so these endpoints carry an auth dimension the
speaker's polling does not. They are deployment-agnostic: the app reaches the
cloud wherever it runs.
So a cloud deployment is essentially the data-plane (both callers) plus
server-side state management (accounts, presets, provider credentials,
diagnostics of stored data). The interactive "do something to a speaker now"
features (both the player and migration) need LAN proximity.
Consequence for the migration tooling (ref the #451 discussion): migration is
**recurring**, not one-shot (you add a speaker later too), and it is
**LAN-bound**. That argues for migration as a local mode/tool you run on the LAN
when needed, rather than always-on code in a cloud binary that could never use
it.
### Trust zones and the current state
The trust zones, mapped to the actors above, and today barely any is guarded:
| Zone | Routes | Client auth today | Should be |
|--------------------|-------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| Speaker contract | frozen (1a), speaker-polled | None (no user login; the app_key is validated but is not user auth) | None, but network-segmentable; in cloud these are necessarily public so the speaker can reach them |
| App / provisioning | account create/login, add/update/remove device, profile, password, groups (`/streaming/account*`, `/customer/account*`) | None enforced (we accept; the app's self-auth from the original model is not required) | Authenticated in cloud: an open provisioning surface lets anyone create accounts or attach devices |
| Admin / setup | `/mgmt/*` (non-callback)<br>`/setup/*`<br>`/web/*` | `/mgmt/*` has single-credential HTTP Basic Auth; **`/setup/*` has none** (explicit "LAN-trust" premise); the Basic Auth even leaks behind a proxy (#419) | Authenticated always; mandatory in cloud |
| Control / player | `/api/control/*` (post-merge) | None | Optional auth; low blast radius |
The "LAN-trust" premise is defensible on a home LAN but **invalid in the cloud**:
`/setup/*` (migration, DNS redirect, trust roots / cert state, account data,
diagnostics, recovery) is wide open, and so is the app/provisioning surface
(anyone could create an account or attach a device). On a public host both are a
real exposure. Closing these gaps is a prerequisite for treating cloud as a
supported deployment.
### Requirements this drives
- **Authentication** on everything user-facing, actually enforced (not
bypassable behind a proxy, see #419). Mandatory for cloud; offered and
recommended for LAN.
- **Authorization tiers** by blast radius (the "authority boundary" from the
#451 landing-page note): a low-privilege user may open the player, while
setup/mgmt (trust roots, migration, accounts) require admin. The landing page
is where that boundary is made explicit.
- **Deployment-aware surface:** in cloud mode, hide/disable the speaker-direct
features (they cannot work) and require auth on the rest; in LAN/on-device
mode, expose the full surface.
These requirements are why the `/api/*` split below is grouped by trust tier:
applying an auth (and later authz) middleware to a whole group is a one-liner,
whereas per-route auth is what produced today's patchy coverage.
### The `deployment-mode` parameter (private / shared / public)
The security posture is an explicit parameter, **default `private`**. It is a
preset over the per-tier auth machinery, not separate architecture: each value
just sets which trust tiers require auth.
| Tier (caller) | private | shared | public |
|------------------------------|---------------|-----------------------|-----------------|
| Speaker contract | none (frozen) | none | none |
| Control / player | open | open | **auth** |
| Admin / setup + provisioning | open (opt-in) | **auth (min. Basic)** | **auth** |
| Speaker-direct features | on | on | hidden/disabled |
- **private** (default): free-for-all, maximum insecurity, security is opt-in.
Matches a trusted single-owner LAN or on-device.
- **public**: opt-out of security. Everything user-facing requires auth; the
surrounding network is untrusted (cloud / internet). Speaker-direct features
are hidden (they cannot work off-LAN anyway).
- **shared**: at least Basic Auth on the structural / admin routes, while the
player stays open. The multi-user trusted-LAN case (guests, kids, roommates):
daily playback without a login, but infrastructure is protected.
The **Speaker-direct features** row is UI gating, not auth. "Speaker-direct"
means actions that reach a speaker on the LAN (discover, migrate, reboot,
volume/play, zone). In `public` the UI hides or disables them because they cannot
work off-LAN, so we do not show buttons that only fail; in `private` / `shared`
they are shown. The UI derives this from the mode.
**Provisioning is treated like admin**, not like the player: creating accounts
and attaching devices is structural / high-blast-radius, so it shares the admin
trust tier (protected in `shared` and `public`). The mechanism may still be Marge
self-auth, but the *requirement* matches admin.
It is a **monotone ladder**: private -> shared adds admin (+ provisioning) auth;
shared -> public additionally locks the player.
**Is `shared` necessary, or just `public`?** It is necessary and distinct. The
only difference between shared and public is the **player tier**: shared keeps it
open (trusted network, frictionless household use), public locks it (untrusted
network). Collapsing them forces either a password on the daily-use player at
home, or an open player on the internet. The cost of keeping `shared` is near
zero once tier-based auth exists (it is just "admin required, player optional"),
so it earns its place as the trusted-LAN-with-privilege-split preset.
Note this 3-value enum compresses two orthogonal axes, network trust (private /
shared = trusted; public = untrusted) and the player/admin privilege split. That
is a deliberate usability simplification over a toggle matrix; if the presets
ever feel too coarse, the underlying per-tier toggles are the escape hatch.
**Configuration and lockout-safety.** `deployment-mode` is set like the other
config: CLI flag, env var, and persisted setting, with the same precedence as
the rest (e.g. like `server-url`). Because of that, the host operator always has
an out-of-band path: even if a mode change in the UI would lock them out, they
can reset it via env / flag / the settings file on the host. The service must
also not let a setting strand its owner: if a mode requires auth but no
credential / provider is configured yet, warn and keep a way in (refuse to apply,
fall back, or allow a loopback/on-host admin bypass) rather than hard-locking the
admin surface.
### Auth posture: opt-none -> opt-in -> opt-out?
The maturity path over releases, which the `deployment-mode` parameter then
expresses per posture:
- **Today: "opt-none".** Auth is not even opt-in. `/mgmt` has a single Basic-Auth
credential (and it leaks behind a proxy, #419); `/setup` and the
app/provisioning surface have nothing. There is effectively no usable way to
turn real auth on. This is `private` before `private` is even a choice.
- **0.x: prepare opt-in.** Make auth something an operator *can* enable
(enforced, not proxy-bypassable; covering the whole admin tier, ideally the
provisioning surface too) and introduce the `deployment-mode` parameter so
`shared` / `public` become selectable. The default stays `private` (security
off) so existing LAN setups are undisturbed.
- **1.x: default still `private`?** The parameter exists, but whether the
shipped default should ever move off `private` is the open call. A cloud-first
stance argues for stricter defaults; the home-LAN majority argues for keeping
`private`. Because the posture is now an explicit parameter, the default can
stay `private` while operators opt into `shared` / `public`, so there is no
need for a hard global flip.
### Auth mechanisms
Three identities, three mechanisms, only the last two are ours to shape:
- **Speaker -> data-plane: fixed, not ours to change.** The speaker authenticates
with a long-lived **Marge account token**, provisioned as an account ID + auth
token (`SetMargeAccount(accountID, authToken)`,
`pkg/service/setup/init_plan.go`); it is *not* given an email/password. This is
part of the frozen contract, so no new auth mechanism can be imposed on the
speaker.
- **Admin -> admin UI: HTTP Basic Auth to start, pluggable later.** We begin with
Basic Auth as the single admin mechanism, but structure it behind one boundary
so additional providers (OIDC, etc.) are easy to add. None of this ever reaches
a speaker; it is purely our app's auth.
- **User -> web app: Marge auth, delegated.** A human (not a speaker) signs into
the player/control UI with their Marge account, and that authentication
**delegates to the existing Marge routes** (`/streaming/account/login` and the
app/provisioning surface). "User auth" thus reuses the same account the speaker
belongs to, rather than a separate user store.
- **Native / non-browser clients (CLI, desktop or mobile app, automation) ->
service.** A whole client class, not just the CLI. Talking to a *speaker's*
local API needs no service auth; talking to *our service*
(cloud/admin/provisioning routes) makes them authenticated clients. Interactive
native clients do OIDC the standard way (RFC 8252, "OAuth 2.0 for Native
Apps"): a loopback `localhost:<port>` redirect (CLI / desktop) or a private-use
URI-scheme redirect (`app://callback`, mobile); the system browser runs the
flow and the client exchanges the code for a token. The case that still needs a
**non-interactive** credential (issued token / API key, or a device-code /
client-credentials grant) is **headless** automation: CI, scripts, no browser.
Requirements on the provider abstraction: (a) support both an interactive path
(browser, including native loopback / custom-scheme redirects) and a headless
token path, and (b) allow registering those redirect URIs (the same
externally-pinned concern as the Spotify/Amazon callbacks).
**Mental model: Marge is an auth provider, like EntraID would be.** The UI auth
sits behind one provider abstraction, and Marge is simply one provider
implementation (the built-in / legacy one) alongside Basic Auth and future OIDC
providers (EntraID, Google, ...). "Sign in with your Marge account" is the same
pattern as "Sign in with EntraID": the app delegates to the provider. Basic Auth,
Marge, and any OIDC provider all implement the same interface, so they are
interchangeable and additive.
Design rule: keep the UI auth pluggable behind that single provider boundary so
new providers slot in without touching the speaker contract (which is not a
provider and never changes) or the Marge delegation.
### Identity in logs
Request logs should carry the resolved caller identity as context, **but only
where the request actually exposes one** (do not fabricate an id the protocol did
not send):
- **Authenticated UI / native / headless clients:** once auth lands, log the
principal (provider subject / username / client id).
- **Speakers:** there is no single speaker login, so it depends on the route.
Many marge/streaming routes embed `{account}` / `{device}` in the path (also
`/v1/scmudc/{deviceId}`, `/v1/stapp/{deviceId}`), so the device/account is
available and worth logging. Others (BMX content like `/bmx/tunein/...`,
`/v1/auth`) carry only a token / app_key or nothing identifying; log what is
present and otherwise leave it blank rather than guessing.
- **Unauthenticated:** mark as anonymous.
Caveats: sanitise the value before logging (the existing log-injection guard,
`sanitizeLog` / `sanitizeErr`), and remember these ids (account / device /
principal) are sensitive, so they must follow the existing log redaction on
diagnostic export, not leak into shared bundles.
## Target layout
```
# Frozen compat layer (top-level, never reshape):
/streaming /accounts /customer /bmx /core02 /oauth /custom
/media /bmx-icons /updates /v1 /alexa /ced
# Our JSON API (everything movable lives here, grouped BY TRUST TIER so
# auth/authz middleware applies per group, not per route):
/api/setup/* (today: /setup/*) -> admin tier: auth required
/api/mgmt/* (today: /mgmt/*, no callbacks) -> admin tier: auth required
/api/control/* (today: soundtouch-player /api/*) -> player tier: auth optional
/api/devices ...
# OAuth provider callbacks (externally-pinned; freeze in place,
# or move only with provider re-registration):
/mgmt/spotify/callback, /mgmt/amazon/callback
# Frontend (one role-gated app, single catch-all, no per-route registration):
/app/* (the unified app; role/auth decides Player vs Setup visibility)
/web/* (legacy admin UI; retired once /app/* subsumes it)
# Infra:
/health /metrics /ws
```
### The `/app/*` pattern
The service's admin UI already does the right thing: `/web/*` is one catch-all
(`HandleWeb`), not one route per page. The `soundtouch-player` SPA routes
(`mount.go`, the `/`, `/devices`, `/tunein`, ... block) are the legacy
anti-pattern. The target:
- **`/app/*`** is a single catch-all that returns `index.html`. The browser does
client-side routing within `/app/`. No frontend path appears in the backend
router.
- **`/api/*`** serves data only.
- Static assets live under a fixed prefix (e.g. `/app/static/*`).
This keeps the backend API free of frontend routes while still avoiding any
need for server-side SPA routing config.
### One app, role-gated (not two apps)
Decision: converge to a **single app** under `/app/*`; role/auth decides what a
user sees (Player vs Setup are views of one app, not separate apps). This is the
natural expression of the trust tiers, removes the duplicated shell / device
handling the two frontends carry today, and lets them share device list and
state (the data-sharing win from the #451 discussion). `/web/*` is retired once
`/app/*` subsumes it.
Two things make this safe:
- **Size (the on-device concern): "one app" is not "one eager bundle."**
Code-split the heavy Setup/Admin surface (migration, certs, DNS, diagnostics,
the ~4.8k-line `script.js`) into a **lazily loaded chunk** that loads only when
an admin navigates there, so the Player path stays light. If size ever gets
tight on-device, a **build tag / flag** can produce a player-only variant that
does not embed the Setup chunk at all. The combined *embedded* size is likely
to *drop*, not grow, since two separate apps duplicate more than one modular
app does; the only real risk is naive eager bundling. Guard it with a
bundle-size / route-count acceptance check (per the #451 discussion): measure
first.
- **Role-gating is UX, not security.** Hiding the Setup views from non-admins is
convenience only. The real boundary stays the **server-side auth middleware**
on the admin / provisioning tiers, otherwise someone just loads the chunk and
calls the routes directly.
## Regression safety: contract tests from the frozen recordings
Build the regression net **before** touching routes. We already record
interactions (`RECORD_INTERACTIONS`) and have a large collection; frozen and
sanitised, that collection becomes a contract suite that proves the refactor
preserves behavior. It is stronger than the router golden file
(`router_routes.txt`), which only checks that routes are registered, not what
they return.
Two directions, matching the two consumers:
- **Speaker contract (highest value): provider-side replay.** The speaker is a
consumer we do *not* control (it is Bose firmware), so this is not classic
consumer-driven Pact: the speaker's real recorded traffic *is* the contract.
Replay each recorded request against the service and assert the response still
matches (body and headers). This pins category-1 byte-for-byte, exactly the
invariant the refactor must not break, and it catches subtle wire details a
route reshuffle could disturb (for example the case-sensitive `ETag` header).
It aligns with the existing parity tests (local vs official Bose recordings).
- **CLI / `/api/*` contract (optional): consumer-driven Pact.** The CLI is a
consumer we *do* control, so real Pact fits: the CLI declares expectations and
the service verifies them. Most useful once the new `/api/*` shape exists, and
to assert **dual-routing equivalence** (old and new path satisfy the same
contract). Lower priority, since this surface is intentionally changing in 0.x.
We are not starting from zero: the existing `tests/integration/http-client/*.http`
suite (run in CI via `make test-http-client` against the service plus the
spotify/amazon mocks) is already a near-consumer-driven contract from the
speaker's perspective. The requests carry the firmware user-agent
(`Bose_Lisa/27.0.6`) and assert status, content-type, and XML structure of the
marge/streaming/BMX routes. It is not literally Pact (no consumer/provider broker
or generated pacts), but it is functionally the speaker contract, and it already
asserts structure and invariants rather than raw bytes, which is exactly the
matcher approach that keeps contracts non-flaky. The natural path is to treat
this suite as the seed and broaden it with the frozen recordings, rather than
inventing a new harness.
How it de-risks the rebuild:
- Pins the frozen speaker contract so a route reshuffle cannot silently alter the
wire.
- During dual-routing, runs the same contract against both old and new paths to
prove the alias is faithful.
- Becomes the gate: the refactor lands only when the contract suite is green.
Caveats:
- **Sanitise before freezing.** Recordings carry real IPs, MACs, account /
device ids, and tokens; per the repo rules they must be anonymised (the
existing testdata anonymisation / rotation) before they become committed
fixtures.
- **Match, do not byte-compare blindly.** Legitimately dynamic fields
(timestamps, tokens, generated ids, ETag *values*) need normalisation /
matchers, or the contracts go flaky. Freeze structure and invariants, not the
volatile bits.
## Staged migration
Everything below happens **within 0.x**. 1.x is only the cutover (removal). The
frozen speaker/app contract routes (category 1) are out of scope throughout: they
never move, so none of the aliasing / redirect / deprecation machinery touches
them.
### Route-transition track (0.x)
1. **Add the new routes, switch the service admin UI to them, alias the old
paths.** Mount `/setup/*` and `/mgmt/*` under the new `/api/*` grouping (chi
`Route`/`Mount`; carve it so `/api/control/*` fits later) and point
`script.js` at the new paths.
- **Use aliasing (dual-mount), not HTTP redirects, for our own routes:**
register the same handler at both the old and new path. It avoids the
client-following and method/body pitfalls of redirects (a redirect would
have to be 307/308 to keep a POST body) and is a no-break upgrade for any
lagging client.
- **Does this work for speaker/legacy routes? No, and it is not needed.** We
never move frozen routes, and a fixed speaker firmware cannot be assumed to
follow a redirect on its marge/BMX calls (untested; do not rely on it). This
step is about our movable routes only.
- **Exclude** `/mgmt/spotify/callback` and `/mgmt/amazon/callback` (1b):
freeze, or move only with a deliberate provider re-registration.
2. **First, migrate `soundtouch-player` in place to the target API shape.** Before
touching the service, restructure the standalone `-web` binary's own routes to
what they should be *after* the merge: the control API under `/api/control/*`
and the SPA under `/app/*` (with `/ws` as e.g. `/api/control/ws`). Unlike the
service, this is a **direct migration, not a dual-mount, and with no
deprecation signal**: `-web`'s only client is its own bundled frontend, served
and reloaded from the same binary, so there are no out-of-band callers to keep
compatible — restructure the routes and update the frontend in lockstep, in
small commits, and a stale tab is fixed by a reload. (The careful
add-alias-then-deprecate dance is reserved for `-service`, which is central and
serves callers we do not control.) The payoff: by the time we merge, `-web`'s
routes already match the target and don't overlap the service's namespaces, so
the merge below is a near-additive mount.
3. **Fold `soundtouch-player` into the service.** Bring the (already target-shaped)
control API in as `/api/control/*` and the UI under `/app/*` (one role-gated
app, see above).
The actual overlap to clean up (verified) is small: only **`/`** truly
collides, so replace the two competing root handlers with a **landing page**
that routes the user to Admin/Setup or the App; **`/health`** is a merge
(keep the service's richer body); **`/ws`** and **`/static/*`** are additive
(the service registers neither, so no collision). Do this cleanup
structurally and verify it (a test that builds the merged router and asserts
no double-registration) rather than hiding overlaps behind the opt-in flag.
Keep the two TuneIn layers separate (frozen `/bmx/tunein/*` vs the player's
`/api/control/*` radio feature).
- **Ship the merged variant behind an opt-in flag (default off).** Its sole
purpose is to let people optionally run the combined binary and give
feedback; it is **not** a collision guard and **not** a security boundary on
its own. Until the auth track lands, default-off keeps the merged app/control
surface from being exposed unless an operator deliberately enables it. The
flag follows the same CLI/env/persisted precedence as `server-url`, and is
the seam the `deployment-mode` parameter later subsumes.
4. **Deprecate the `soundtouch-player` binary.** It keeps working in 0.x but prints
a startup deprecation warning (along the lines of "this binary is removed in
1.x, use soundtouch-service") so its removal is no surprise.
5. **Warn on old-route hits in the service, observably.** When a deprecated path
is called, log a deprecation warning **and** count it (a metric / signal), so
the 1.x removal is data-driven: a route is only cut once it has gone quiet
across real deployments, not on a guess. *(Done for the `/setup` and `/mgmt`
legacy paths via `DeprecatedRouteMiddleware`; extends to any future aliased
route.)*
### Auth track (0.x, parallel)
- Group `/setup/*` + `/mgmt/*` (+ provisioning) into one admin tier and apply a
single auth middleware, replacing today's per-route gap (`/mgmt` has Basic
Auth, `/setup` has none).
- Make auth enforceable behind a reverse proxy (close #419), not dependent on a
header a proxy can strip.
- Land the `deployment-mode` parameter (private / shared / public) with its
lockout-safety, and the speaker-direct UI gating.
- Authorization (player vs admin tiers) can follow authentication; design the
groups now so it slots in without another reshuffle.
### Before 1.x: definition of done
1.x removes the old routes and the deprecated binary, so all of this must be true
in a 0.x release first:
- **Auth / `deployment-mode` actually shipped** and opt-in works. This is the
cloud-first motivation; without it 1.x has no payoff.
- **Every client we ship moved off the old paths:** the admin UI, the merged
app, the **CLI**, the **HTTP-client integration tests**, **docs and examples**,
any reverse-proxy guide. The 0.x dual-routing is their migration window, but
someone has to actually move them.
- **Old-route usage has gone quiet** in the step-4 signal (do not remove blind).
- **A deprecation window of at least one release** where the warnings were live.
- **A user-facing migration note / changelog entry.**
- The router golden file (`router_routes.txt`) and the contract suite (above)
kept green throughout; they are the regression guards.
### 1.x cutover
Remove the obsolete routes and retire `soundtouch-player`. Per the versioning
section, this is the only point where anything is removed; the frozen
speaker/app routes stay.
## Versioning and the 1.x cutover
We do **not** version our own API in the path (`/api/v1/...`). In practice path
versioning buys little; its one real benefit is explicitness, and it can be
retrofitted later if a hard break ever forces it. Either way, a `/v1` -> `/v2`
bump does not remove the need to be careful when changing or breaking a route.
(The frozen `/v1/*` routes in the tables above are Bose's firmware contract, not
our versioning. They are unrelated.)
Versioning lives at the **release level (semver)** instead:
- **0.x (now):** the API may evolve. When a route moves, the **old and new paths
stay live at the same time** (the alias/redirect layer from step 1). Every
release stays a no-break upgrade, which gives users time to follow.
- **1.x (the cutover):** the release where we settle on the better API. At 1.x we
**remove the obsolete routes**. That is the only point where an old route
disappears.
Why this is low-risk: the service and the frontend(s) it serves ship in **one
binary**. A user updates the service and reloads the browser tab; the reloaded
SPA is the client for the new API, so the two always match, with no window where
an old frontend talks to a new backend.
Caveat: this holds for the clients we ship (the bundled UIs). Out-of-band callers
that hardcode paths (the CLI, user scripts, reverse-proxy rules, the HTTP-client
integration tests) must follow by 1.x as well; the 0.x dual-routing is precisely
the window that lets them. The frozen speaker/app contract routes are never
removed, 1.x included.
## Open questions
- Lockout-safety mechanism: which of refuse-to-apply / fall-back / loopback-on-
host bypass we use when a mode requires auth but none is configured yet.
- The form of the non-interactive credential for native / headless clients:
issued token, API key, device-code, or client-credentials grant.
- The shipped default at 1.x: stay `private`, or move to a stricter default
(the parameter lets operators opt in regardless, so no hard flip is forced).
@@ -156,7 +156,7 @@ Where today's surfaces fall short for this user:
**Goal.** Music plays. Pressing preset 3 gives them what preset 3 should give them. Skipping a station, adjusting volume, browsing for a new station — all fast, no friction.
**Surfaces.** Physical preset buttons (always there), `soundtouch-web` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
**Surfaces.** Physical preset buttons (always there), `soundtouch-player` (today), mobile app (Journey 2 admin app's daily-use mode), WASM-served browser UI (planned), Bose app while it still functions, voice assistants where wired up.
### What this layer needs to be good at
@@ -168,14 +168,14 @@ Where today's surfaces fall short for this user:
### How surfaces map
- `soundtouch-web`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
- `soundtouch-player`: primary daily UI for desktop browsers and (responsively) for tablets. This is already shipped.
- Mobile app: daily-use mode of the same Gio app that handles admin. Capability split — admin features only show up when the user is in admin mode.
- WASM: same Gio app, served from `soundtouch-service` to anyone on the LAN. The "I forgot which device my login is on, just open a browser" fallback.
- Physical preset buttons: handled at the agent level (the Bose firmware fires them; AfterTouch or the on-device agent reacts).
### Open decisions for this journey
- Do we keep `soundtouch-web` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
- Do we keep `soundtouch-player` as a separate codebase (HTML/JS), or does it become a Gio WASM build sharing code with the admin app?
- Mobile app store distribution: TestFlight for iOS (gated, slow), Play Store for Android (faster, AAB only), F-Droid as an open-source-friendly side path.
- Multi-user state: presets per-user vs per-household. Out of scope here, but the daily surface is where it gets felt.
@@ -199,7 +199,7 @@ Where today's surfaces fall short for this user:
### How surfaces map
- `soundtouch-cli`: the canonical surface for scripted control. Already covers most of the API.
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-web` and by third-party automation.
- `soundtouch-service` REST endpoints: same surface, network-accessible. Used by `soundtouch-player` and by third-party automation.
- Home Assistant: external integration; track but do not own.
- Webhooks / MQTT: not present today; would let speakers participate in event-driven flows. Out of scope for a first pass; worth a separate design doc when demand surfaces.
@@ -217,7 +217,7 @@ Where today's surfaces fall short for this user:
|------------------------------------|---------------------|-------------------|-------------------|------------------------|
| `soundtouch-cli` | partial (today) | partial (today) | no | primary |
| `soundtouch-service` web UI | wizard portion | primary | partial | indirect (REST) |
| `soundtouch-web` | no | no | primary | no |
| `soundtouch-player` | no | no | primary | no |
| GUI admin app (Gio, planned) | primary | primary | mobile mode | no |
| Pre-flashed stick (hypothetical) | primary | recovery | no | no |
| Physical preset buttons | no | no | primary | no |
@@ -229,7 +229,7 @@ The diagonal isn't full because some journeys lack a polished surface today (Jou
The Gio admin app, if built, can target Windows / macOS / Linux / iOS / Android / WASM from one codebase. Each target has hard constraints:
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-web`.
- **WASM (browser).** Post-install REST control, device list and status, preset editing, station search. No mDNS (browsers cannot do raw multicast — fall back to manual IP entry or a backend bridge); no raw TCP, so no SSH and no install; no block-device access, so no stick writing. This is the "I just want to use my speakers" surface, equivalent to today's `soundtouch-player`.
- **Mobile iOS.** Everything WASM does, plus Bonjour-based mDNS, plus full SSH client (so app-driven install and recovery work). No FAT32 stick writing — iOS has no filesystem-level block device access for third-party apps. Best paired with a pre-flashed stick or a friend's desktop install for the bootstrap.
- **Mobile Android.** Same as iOS, plus FAT32 stick writing *if* the user grants USB-OTG host permission. UX caveat: most users will not know what USB host mode is.
- **Desktop (Gio).** Full capability set. mDNS, SSH-driven install, FAT32 stick writing via standard block-device APIs, post-install control, recovery. The primary onboarding surface.
+9
View File
@@ -2,3 +2,12 @@
title: "Architecture"
weight: 5
---
Architecture notes and analyses:
- [API Route Layout and Refactoring Plan](API-ROUTE-LAYOUT.md) - route
classification (frozen speaker contract vs our movable surface), the
actor / deployment / trust model, auth, and the staged plan toward the
`soundtouch-player` / `soundtouch-service` merge (issue #451).
- [Device-Local Install: Four User Journeys](DEVICE-LOCAL-INSTALL.md) - install
patterns and user journeys for on-device deployment.
@@ -91,7 +91,7 @@ that delegates to AfterTouch for these names). The implementation lives in
> **IP-based `--server-url` is incompatible with OAuth (both Spotify and Amazon
> Music).** The speaker's hostname construction appends `oauth` to the first
> label only, so `192.168.0.30` would produce `192oauth.168.0.30` — malformed,
> label only, so `192.0.2.30` would produce `192oauth.0.2.30` — malformed,
> no DNS resolver will answer for it, and there is no clean workaround on the
> AfterTouch side. **Use a real LAN hostname** before configuring Spotify or
> Amazon Music. The Health-tab `oauth_target_reachable` check warns when this
+52 -20
View File
@@ -904,39 +904,71 @@ Search for and manage radio stations and streaming content.
Search and manage stations.
##### Built-in search: the `find` family (recommended)
The `find` commands run the search **inside the CLI itself**, querying the
radio provider's public API directly. They need **neither the speaker's
cloud nor a running `soundtouch-service`**, and they don't require a
reachable speaker (`--host`) to search — so they keep working even after
the speaker's original cloud is gone. This is the recommended way to
search.
- `station find --provider tunein|radiobrowser --query <term>` — unified
built-in search. `--provider` defaults to `tunein`.
- `station find-tunein --query <term>` — TuneIn sibling
(= `find --provider tunein`).
- `station find-radiobrowser --query <term>` — Radio Browser sibling
(= `find --provider radiobrowser`).
- `… --more` — follow up to three additional result pages when available
(both TuneIn and Radio Browser paginate).
Results include each station's playback `Location`, which you can feed to
`source tunein` (TuneIn) or a preset/play flow.
```bash
# Search across any source
# Unified built-in search (no speaker required)
soundtouch-cli station find --provider tunein --query "jazz"
# TuneIn sibling
soundtouch-cli station find-tunein --query "jazz"
# Radio Browser, walking extra result pages
soundtouch-cli station find-radiobrowser --query "jazz" --more
```
##### Deprecated: speaker-based search
These commands ask the **speaker** to search, which only works while the
speaker's cloud source is reachable. They are **deprecated** — each prints
a deprecation notice — and will be removed in a future release. Prefer the
`find` family above. There is no built-in equivalent for Pandora or
Spotify *yet*; those still require the speaker and your account.
```bash
# [DEPRECATED] Search across any source via the speaker → use `station find`
soundtouch-cli --host <device> station search --source <SOURCE> --query <SEARCH_TERM>
# Search TuneIn specifically
# [DEPRECATED] Search TuneIn via the speaker → use `station find-tunein`
soundtouch-cli --host <device> station search-tunein --query <SEARCH_TERM>
# Search Pandora specifically (requires account)
# [DEPRECATED] Search Pandora via the speaker (no built-in equivalent yet)
soundtouch-cli --host <device> station search-pandora --source-account <ACCOUNT> --query <SEARCH_TERM>
# Search Spotify specifically (requires account)
# [DEPRECATED] Search Spotify via the speaker (no built-in equivalent yet)
soundtouch-cli --host <device> station search-spotify --source-account <ACCOUNT> --query <SEARCH_TERM>
```
##### Manage stations
```bash
# Add station and play immediately
soundtouch-cli --host <device> station add --source <SOURCE> --token <TOKEN> --name <NAME>
# Remove station from collection
soundtouch-cli --host <device> station remove --source <SOURCE> --location <LOCATION>
```
**Search Examples:**
```bash
# Search TuneIn for jazz stations
soundtouch-cli --host 192.0.2.10 station search-tunein --query "jazz"
# Search Pandora for Taylor Swift
soundtouch-cli --host 192.0.2.10 station search-pandora --source-account myuser123 --query "Taylor Swift"
# Search Spotify for workout playlists
soundtouch-cli --host 192.0.2.10 station search-spotify --source-account spotify_user --query "workout playlist"
# General search across any source
soundtouch-cli --host 192.0.2.10 station search --source TUNEIN --query "classic rock"
# List saved stations for a source
soundtouch-cli --host <device> station list --source <SOURCE> [--source-account <ACCOUNT>]
```
**Station Management Examples:**
@@ -962,8 +994,8 @@ soundtouch-cli --host 192.0.2.10 station remove \
**Workflow Example - Discover and Play New Content:**
```bash
# 1. Search for content
soundtouch-cli --host 192.0.2.10 station search-tunein --query "smooth jazz"
# 1. Search for content (built-in, no speaker needed)
soundtouch-cli station find-tunein --query "smooth jazz"
# 2. Add interesting station from results (copy token from output)
soundtouch-cli --host 192.0.2.10 station add \
@@ -40,7 +40,7 @@ starts on boot.
To install a specific version:
```bash
sudo bash install.sh v0.93.1
sudo bash install.sh v0.107.0
```
Check that the service is running:
@@ -167,19 +167,40 @@ curl -s http://192.0.2.1:8090/sources
## Step 7 — Set up preset buttons (optional)
### Via soundtouch-web
### Via soundtouch-player
The Radio Browser, TuneIn tabs, and preset saving live in
**soundtouch-web**, a separate binary from the service. Run it on your
host and open **`http://<host-ip>:8080`** in your browser (default port
8080).
**soundtouch-player**, a separate binary from the service. Once running,
open **`http://<host-ip>:8080`** in your browser (default port 8080).
> **Raspberry Pi note:** The Raspberry Pi installer (`install.sh`) only
> installs `soundtouch-service`. Download `soundtouch-web` separately from
> the [Releases page](https://github.com/gesellix/Bose-SoundTouch/releases)
> and start it alongside the service.
### Installing soundtouch-player on a Raspberry Pi
soundtouch-web provides two ways to save what's currently playing to a
`install.sh` only installs `soundtouch-service`. Use the dedicated
`install-web.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
```
For configuration, service management, updates, and removal see the
[Raspberry Pi guide → soundtouch-player](RASPBERRY-PI.md#soundtouch-player).
### Installing soundtouch-player on other hosts
Download the binary for your OS and architecture from the
[Releases page](https://github.com/gesellix/Bose-SoundTouch/releases)
and run it directly:
```bash
./soundtouch-player --port 8080
```
Or install it as a systemd service following the same unit-file pattern
described in [DEPLOYMENT.md](DEPLOYMENT.md).
soundtouch-player provides two ways to save what's currently playing to a
preset slot:
**★ Star button in the Now Playing card**
@@ -242,7 +263,7 @@ curl -s http://192.0.2.1:8090/presets
```bash
sudo bash install.sh # updates to latest release
sudo bash install.sh v0.93.1 # updates to a specific version
sudo bash install.sh v0.107.0 # updates to a specific version
```
The installer stops the service, downloads the new binary, and restarts
@@ -10,9 +10,6 @@ by [weissigera](https://github.com/weissigera) in
[issue #329](https://github.com/gesellix/Bose-SoundTouch/issues/329#issuecomment-4521280831),
documenting a successful fresh installation on a SoundTouch 20 Series I.
For the installer reference and troubleshooting tips see
[scripts/on-device-install/README.md](https://github.com/gesellix/Bose-SoundTouch/blob/main/scripts/on-device-install/README.md).
---
## Prerequisites
@@ -88,10 +85,10 @@ To target a specific version instead of the default:
```bash
# Via environment variable (works with pipe-to-sh)
VERSION=0.92.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
VERSION=0.107.0 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.92.0
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
```
Verify the installed version:
@@ -100,7 +97,7 @@ Verify the installed version:
wget -qO- http://localhost:8000/health
```
The JSON response should include `"version":"v0.93.1"` (or whichever
The JSON response should include `"version":"v0.107.0"` (or whichever
version you installed).
---
@@ -188,13 +185,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.93.1/soundtouch-cli-v0.93.1-linux-armv7
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.107.0/soundtouch-cli-v0.107.0-linux-armv7
chmod +x soundtouch-cli
/tmp/soundtouch-cli --version
```
Replace `v0.93.1` with the version you installed.
Replace `v0.107.0` with the version you installed.
---
@@ -292,6 +289,72 @@ should start playing the corresponding stream.
| `http://localhost:8000` not responding after install | `logread \| grep aftertouch \| tail -20` |
| No space left on device during install | Run the cleanup in Step 2; check `df -h /mnt/nv` |
For more detail on any of these, see
[TROUBLESHOOTING.md](./TROUBLESHOOTING.md) and the
[on-device installer README](https://github.com/gesellix/Bose-SoundTouch/blob/main/scripts/on-device-install/README.md).
For more detail see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
---
## Updating AfterTouch
Re-run the installer with the version you want. The script backs up the
running binary (named after its version), installs the new one, and prunes
older artefacts to keep `/mnt/nv` free:
```bash
# Update to latest release
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
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.107.0
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
```
**Rollback:** the installer keeps a `.backup` file alongside the binary:
```bash
ls /mnt/nv/aftertouch/aftertouch-service*.backup
cp /mnt/nv/aftertouch/aftertouch-service.<old-version>.backup \
/mnt/nv/aftertouch/aftertouch-service
/etc/init.d/aftertouch restart
```
---
## Service management
```bash
/etc/init.d/aftertouch start
/etc/init.d/aftertouch stop
/etc/init.d/aftertouch restart
/etc/init.d/aftertouch status # distinguishes "running + listener up" from "PID alive but listener down"
```
---
## Logs
The daemon writes to BusyBox syslog (tagged `aftertouch`). Disk usage stays
bounded — the syslog ring buffer is in memory:
```bash
logread | grep aftertouch | tail -20 # recent entries
logread -f | grep aftertouch # live tail
```
If the service is running but port 8000 isn't responding, check the syslog
tail first — panics and startup errors appear there.
---
## Uninstalling
Before uninstalling, consider reverting the speaker migration from the
AfterTouch Admin UI so the speaker URL is set back to the Bose cloud (though
neither Bose nor AfterTouch will be reachable once both are removed).
```bash
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/uninstall.sh | sh
```
+235 -40
View File
@@ -1,75 +1,270 @@
---
title: "Raspberry Pi Installation Guide"
---
This guide explains how to install the `soundtouch-service` as a persistent systemd service on a Raspberry Pi (tested on Raspberry Pi Zero 2W, 3, and 4).
How to install and manage AfterTouch on a Raspberry Pi (or any always-on Linux
host) using the provided installer scripts.
For a complete walkthrough — from install through speaker migration and preset setup — see
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 |
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.
For a complete install-through-migration walkthrough see
[EXTERNAL-HOST-WALKTHROUGH.md](EXTERNAL-HOST-WALKTHROUGH.md).
Not sure whether to use a Pi or run AfterTouch on the speaker itself? See
[DEPLOYMENT-OVERVIEW.md](DEPLOYMENT-OVERVIEW.md).
## Automated Installer
---
We provide a specialized installer script located in the `scripts/raspberry-pi/` directory of the repository.
## soundtouch-service
### Features
* **Automatic start on boot**: Installs a systemd unit.
* **Non-root operation**: Uses `AmbientCapabilities` to bind to ports 80/443 without root privileges.
* **Arch Detection**: Automatically selects the correct binary for `armv7`, `arm64`, or `amd64`.
* **Easy Updates**: Re-running the script updates the binary to the latest version.
### Installation
### Installation Steps
```bash
curl -fsSL -o install.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install.sh
sudo bash install.sh
```
1. **Download the installer**:
```bash
curl -fsSL -o install.sh https://raw.githubusercontent.com/gesellix/bose-soundtouch/main/scripts/raspberry-pi/install.sh
```
Install a specific version:
2. **Run with sudo**:
```bash
sudo bash install.sh
```
```bash
sudo bash install.sh v0.107.0
```
### Overriding Defaults
You can customize the installation using environment variables:
Override defaults at install time:
```bash
sudo \
VERSION=v0.93.1 \
VERSION=v0.107.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
bash install.sh
```
### Updating the Service
### Configuration
To update the service to a specific version, run the installer with the version as an argument:
```bash
sudo bash install.sh v0.93.1
```
/etc/soundtouch-service/soundtouch-service.env
```
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
## Management
Once installed, use standard `systemctl` commands to manage the service:
Example:
```bash
# Check status
systemctl status soundtouch-service
PORT=80
HTTPS_PORT=443
DATA_DIR=/var/lib/soundtouch-service
# Follow logs
journalctl -u soundtouch-service -f
LOG_PROXY_BODY=false
REDACT_PROXY_LOGS=true
RECORD_INTERACTIONS=true
DISCOVERY_INTERVAL=5m
# Restart
SERVER_URL=http://soundtouch.local
HTTPS_SERVER_URL=https://soundtouch.local
```
After editing the env file:
```bash
sudo systemctl restart soundtouch-service
```
## Configuration
### Service management
Configuration is stored in `/etc/soundtouch-service/soundtouch-service.env`. Note that settings saved via the Web UI (in `settings.json`) will take precedence over these environment variables once the service is running.
```bash
systemctl status soundtouch-service
sudo systemctl enable soundtouch-service # start on boot
sudo systemctl disable soundtouch-service
sudo systemctl stop soundtouch-service
sudo systemctl start soundtouch-service
sudo systemctl restart soundtouch-service
```
For more details, see the [scripts/raspberry-pi/README.md](https://github.com/gesellix/Bose-SoundTouch/blob/main/scripts/raspberry-pi/README.md) in the repository.
### Logs
```bash
journalctl -u soundtouch-service -e --no-pager # recent
journalctl -u soundtouch-service -f # follow live
journalctl -u soundtouch-service -b # this boot only
```
### Updates
```bash
sudo bash install.sh # update to latest release
sudo bash install.sh v0.107.0 # update to a specific version
```
The script stops the service, downloads the new binary (backs up the old one to
`.old`), and restarts automatically. Your env file and data directory are preserved.
### Removal
```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
```
---
## soundtouch-player
`soundtouch-player` is a stateless browser control panel — it holds no persistent
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
```
Install a specific version:
```bash
sudo bash install-web.sh v0.107.0
```
Override defaults at install time:
```bash
sudo \
VERSION=v0.107.0 \
HTTP_PORT=8081 \
bash install-web.sh
```
Once running, open **`http://<pi-ip>:8080`** in a browser.
### Configuration
```
/etc/soundtouch-player/soundtouch-player.env
```
Example:
```bash
PORT=8080
BIND_ADDR=
DISCOVERY_INTERFACE=
SOUNDTOUCH_DEVICES=
SERVICE_URL=
SERVICE_CA=
```
`SOUNDTOUCH_DEVICES` accepts a comma-separated list of IP addresses for manual
device registration — useful when mDNS auto-discovery is unreliable on your
network:
```bash
SOUNDTOUCH_DEVICES=192.0.2.1,192.0.2.2
```
`SERVICE_URL` links `soundtouch-player` to your `soundtouch-service` instance,
which is required for Text-to-Speech ("Speak"). When the service is served
over HTTPS with its own self-signed certificate (the default), also set
`SERVICE_CA` to that CA certificate, or the proxied TTS call fails with
`x509: certificate signed by unknown authority`. The CA is the service's
`<dataDir>/certs/ca.crt` (also downloadable from `GET /setup/ca.crt`). For
example:
```bash
SERVICE_URL=https://soundtouch.local
SERVICE_CA=/var/lib/soundtouch-service/certs/ca.crt
```
With a plain `http://` `SERVICE_URL`, `SERVICE_CA` is unused (no TLS) and can
be left empty.
After editing the env file:
```bash
sudo systemctl restart soundtouch-player
```
### Port conflicts
Port 8080 is a common default for other services. To check what is already
using it:
```bash
sudo ss -tulpn | grep :8080
```
To use a different port, pass `HTTP_PORT=<port>` to the installer, or edit
the env file after installation and restart the service.
### Service management
```bash
systemctl status soundtouch-player
sudo systemctl enable soundtouch-player # start on boot
sudo systemctl disable soundtouch-player
sudo systemctl stop soundtouch-player
sudo systemctl start soundtouch-player
sudo systemctl restart soundtouch-player
```
### Logs
```bash
journalctl -u soundtouch-player -e --no-pager
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
```
### Removal
```bash
sudo systemctl disable --now soundtouch-player
sudo rm /etc/systemd/system/soundtouch-player.service
sudo rm -rf /etc/soundtouch-player
sudo rm /usr/local/bin/soundtouch-player
sudo systemctl daemon-reload
```
---
## Architecture auto-detection
Both installers detect the CPU and pick the matching release asset automatically:
| `uname -m` | asset suffix |
|---------------------|---------------|
| `aarch64` | `linux-arm64` |
| `armv7l` / `armv6l` | `linux-armv7` |
| `x86_64` | `linux-amd64` |
Override if needed:
```bash
sudo ARCH_ASSET=linux-arm64 bash install.sh
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
```
---
## Security
Both services run as the `soundtouch` system user (no login shell, no home
directory). `soundtouch-service` additionally uses
`AmbientCapabilities=CAP_NET_BIND_SERVICE` to bind ports 80 / 443 without root.
@@ -30,7 +30,7 @@ The service consists of several key components:
- **Service Registry**: Media service discovery and configuration
- **Playback Control**: Stream URL resolution and audio metadata
![soundtouch-web TuneIn search — browsing smooth jazz stations](/images/soundtouch-web-tunein.png)
![soundtouch-player TuneIn search — browsing smooth jazz stations](/images/soundtouch-player-tunein.png)
### Marge Services (Account & Device Management)
- **Account Management**: User account simulation and device association
@@ -390,7 +390,7 @@ Lists all discovered SoundTouch devices with their current status.
```json
[
{
"device_id": "08DF1F0BA325",
"device_id": "AABBCCDDEE0A",
"name": "Living Room Speaker",
"ip_address": "192.0.2.100",
"product_code": "SoundTouch 20",
+2 -2
View File
@@ -11,14 +11,14 @@ Bose shut down SoundTouch cloud services on **May 6, 2026**. Per the [official e
What **continues to work** regardless:
- The official SoundTouch app for local control (play/pause/volume/source selection)
- Local playback controls via `soundtouch-cli`, `soundtouch-web`, or any app that uses the local Web API
- Local playback controls via `soundtouch-cli`, `soundtouch-player`, or any app that uses the local Web API
- Bluetooth, AUX, and AirPlay inputs
- Multiroom zones (local, peer-to-peer)
**AfterTouch** — the `soundtouch-service` — restores the first three:
- **Presets** — full preset management including long-press assignment and recently-played sync; music service presets (Spotify, TuneIn, etc.) work once the service is linked (see [Connecting Music Services](MUSIC-SERVICES.md))
- **Music browsing and playback** — TuneIn, Internet Radio, and RadioBrowser via `soundtouch-web`; direct station/URL playback via `soundtouch-cli`; Spotify via Spotify Connect (speaker-native) or AfterTouch's OAuth integration; Amazon Music OAuth infrastructure is in place but streaming is not yet verified
- **Music browsing and playback** — TuneIn, Internet Radio, and RadioBrowser via `soundtouch-player`; direct station/URL playback via `soundtouch-cli`; Spotify via Spotify Connect (speaker-native) or AfterTouch's OAuth integration; Amazon Music OAuth infrastructure is in place but streaming is not yet verified
- **Stereo pairing** — via `soundtouch-cli`
Alexa voice commands are not currently supported.
+133 -10
View File
@@ -316,7 +316,7 @@ avahi-resolve -n soundtouch.local
```go
nowPlaying, err := client.GetNowPlaying()
if err == nil {
fmt.Printf("Status: %s, Source: %s\n",
fmt.Printf("Status: %s, Source: %s\n",
nowPlaying.PlayStatus, nowPlaying.Source)
}
```
@@ -326,7 +326,7 @@ if err == nil {
sources, err := client.GetSources()
if err == nil {
for _, source := range sources.Sources {
fmt.Printf("Source: %s, Status: %s\n",
fmt.Printf("Source: %s, Status: %s\n",
source.Source, source.Status)
}
}
@@ -490,7 +490,7 @@ if err == nil {
// Only zone master can control volume
if zoneStatus == "MEMBER" {
fmt.Println("Device is zone member - only master controls volume")
// Find and use master device
zone, _ := client.GetZone()
// Connect to master device using zone.Master ID
@@ -507,7 +507,7 @@ client.DecreaseVolume(5)
3. **Check Current Volume:**
```go
volume, _ := client.GetVolume()
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
fmt.Printf("Target: %d, Actual: %d, Muted: %t\n",
volume.TargetVolume, volume.ActualVolume, volume.Muted)
```
@@ -586,7 +586,7 @@ curl http://192.0.2.10:8090/playNotification
**Causes & Solutions:**
#### 1. **Device Model Compatibility**
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
- ✅ **Supported**: SoundTouch 10 (ST-10), SoundTouch 20 (ST-20)
- ❌ **Not Supported**: SoundTouch 300 (ST-300), older models
**Solution:** Verify device model with:
@@ -619,10 +619,56 @@ Only one notification can play at a time. Wait a few seconds and retry.
#### 2. **Check Current Playback Status**
```go
nowPlaying, _ := client.GetNowPlaying()
fmt.Printf("Current source: %s, status: %s\n",
fmt.Printf("Current source: %s, status: %s\n",
nowPlaying.Source, nowPlaying.PlayStatus)
```
### ❌ soundtouch-player TTS fails with `certificate signed by unknown authority`
**Symptoms:**
```
TTS service request failed: Post "https://soundtouch.fritz.box/setup/tts/speak":
tls: failed to verify certificate: x509: certificate signed by unknown authority
```
**Cause:** TTS synthesis and the Bose app key live in `soundtouch-service`,
so `soundtouch-player` proxies the "Speak" action to the service. When the
service is served over HTTPS with its own self-signed certificate (the
default — see `GET /setup/ca.crt`), `soundtouch-player` doesn't trust that CA out
of the box, so the proxied call fails verification.
**Solution:** start `soundtouch-player` with `--service-ca` pointing at the
service's CA certificate (its `<dataDir>/certs/ca.crt`, or the file served at
`/setup/ca.crt`):
```bash
soundtouch-player \
--service-url https://soundtouch.fritz.box \
--service-ca /path/to/certs/ca.crt
```
`SERVICE_CA` is the equivalent environment variable. The CA is appended to the
system trust store, so a service URL that uses a publicly trusted certificate
needs no flag.
### ❌ soundtouch-player TTS returns `host ... is not a known device`
**Symptoms:**
```
TTS service returned 400: {"error":"host http://192.0.2.10:8090 is not a known device"}
```
**Cause:** the service only plays TTS on speakers it knows (an SSRF guard:
the target is matched against the service's device datastore, never taken
verbatim from the request).
**Solution:** make sure the target speaker is known to `soundtouch-service`
(discovered or manually added, and migrated to AfterTouch), not only to
`soundtouch-player`'s own discovery. Check with `GET /setup/devices` on the
service. (Recent `soundtouch-player` versions identify the speaker by its device
ID and a bare IP, so this error otherwise indicates the speaker simply isn't
registered with the service.)
---
## 📡 **WebSocket Issues**
@@ -834,7 +880,7 @@ config.Logger = &client.DefaultLogger{} // Or custom logger
# Capture SoundTouch traffic
sudo tcpdump -i any host 192.0.2.100 and port 8090
# Monitor WebSocket traffic
# Monitor WebSocket traffic
sudo tcpdump -i any host 192.0.2.100 and port 8080
# HTTP debugging with curl
@@ -978,7 +1024,7 @@ Use this checklist to systematically troubleshoot issues:
### Network Connectivity
- [ ] Device power LED is solid white
- [ ] Both devices on same network subnet
- [ ] Both devices on same network subnet
- [ ] Firewall allows ports 8090 (HTTP) and 8080 (WebSocket)
- [ ] Can ping device IP address
- [ ] Can telnet to ports 8090 and 8080
@@ -995,7 +1041,7 @@ Use this checklist to systematically troubleshoot issues:
- [ ] Proper error handling
- [ ] Resource cleanup (defer statements)
### Multiroom Specific
### Multiroom Specific
- [ ] All devices support multiroom
- [ ] Device IDs are correct (from GetDeviceInfo)
- [ ] Devices on same network subnet
@@ -1039,6 +1085,83 @@ cat data/accounts/1000001/devices/*/DeviceInfo.xml | grep macAddress
---
## 🌐 **Cross-subnet / VLAN isolation** {#cross-subnet}
### ❌ AfterTouch unreachable when speaker and server are on different subnets
**Symptoms:**
- AfterTouch is running and reachable from your computer, but the speaker cannot
connect to it after migration.
- `logread | grep aftertouch` on the speaker shows no outgoing requests, or shows
`Curl 7, http 0` for the AfterTouch host.
- Everything works when speaker and server are on the same `/24` subnet, but fails
when they are on different VLANs (e.g. IoT VLAN `192.168.20.x` vs server VLAN
`192.168.10.x`).
**Cause:**
Since a 2018 firmware update, SoundTouch devices apply an iptables policy that
blocks incoming traffic from subnets other than their own. The policy lives in
`/etc/init.d/Firewalls/update_iptables` inside the `block_remote_traffic()`
function.
**Fix A — Add an explicit ACCEPT rule for your AfterTouch subnet (targeted):**
SSH into the speaker and edit the file:
```bash
ssh -oHostKeyAlgorithms=+ssh-rsa root@<speaker-ip>
rw
cd /etc/init.d/Firewalls/
vi update_iptables
```
In `vi`, find `block_remote_traffic()`. Press `i` to enter insert mode. After the
first `done` line in that function, add:
```
echo -A INPUT -i $IFACE -s 192.0.2.0/24 -j ACCEPT
```
Replace `192.0.2.0/24` with the subnet your AfterTouch host is on. Press `Esc`,
type `:wq`, press `Enter`, then reboot:
```bash
reboot
```
To allow **all** subnets, use a wider CIDR range such as `192.168.0.0/16` or
`0.0.0.0/0`.
**Fix B — Comment out the DROP rule (simpler, allows all inbound traffic):**
Instead of adding an ACCEPT rule, find the `DROP` line inside
`block_remote_traffic()` and comment it out by prepending `#`:
```bash
# Before:
echo -A INPUT -i $IFACE ! -s $ADDR/$CIDR -j DROP
# After:
# echo -A INPUT -i $IFACE ! -s $ADDR/$CIDR -j DROP
```
This is less targeted than Fix A but simpler if you run the speaker in an already
firewalled network.
**Related: ST20 Series I also blocks outbound connections to non-standard ports**
Some older firmware images (observed on SoundTouch 20 Series I) also apply an
outbound iptables policy that blocks connections to ports other than 80 and 443.
If AfterTouch is running on a non-standard port (e.g. 8000, 8080) and the speaker
simply never reaches it, this policy may be the cause. Fix A and Fix B apply
equally — inspect `update_iptables` for matching DROP rules on the OUTPUT chain.
*This behaviour was first documented in
[Discussion #354](https://github.com/gesellix/Bose-SoundTouch/discussions/354).*
---
## 🌐 **Hostname Resolution** {#hostname-resolution}
### Why the service resolves the hostname from the device
@@ -1163,4 +1286,4 @@ go run ./cmd/soundtouch-cli -host <ip> -network-info
- **Examples**: Review `/examples` for working code patterns
- **CLI Tool**: Use built-in CLI for testing and debugging
Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
Remember: Most issues are network-related. Start with basic connectivity testing before investigating code issues.
@@ -3,6 +3,8 @@ title: "Bose SoundTouch Web API - Endpoints Overview"
---
This document provides a comprehensive overview of the available API endpoints verified against the official Bose SoundTouch Web API v1.0 specification (January 7, 2026).
> **Note:** This documents the *speaker device* Web API (port 8090). For the AfterTouch *service's* own route layout (cloud emulation vs admin/control surface) and the planned refactoring, see [API Route Layout and Refactoring Plan](../architecture/API-ROUTE-LAYOUT.md).
**Acknowledgment**: Additional endpoints beyond the official API were discovered through the comprehensive [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API) maintained by the SoundTouch Plus community. Special thanks to @thlucas1 and contributors for documenting these working endpoints that enable full preset management and content navigation functionality.
## Implementation Status Legend
+2
View File
@@ -3,6 +3,8 @@ title: "Bose SoundTouch Cloud API Emulation (Marge/BMX/Stats)"
---
This document describes the cloud-emulation APIs provided by the SoundTouch service. These APIs mimic the Bose cloud services (Marge, BMX, Stats) that SoundTouch devices and the SoundTouch controller application (Stockholm) interact with.
> **See also:** [API Route Layout and Refactoring Plan](../architecture/API-ROUTE-LAYOUT.md) - how these cloud-emulation routes are classified (frozen speaker contract vs our own movable surface) and the planned API consolidation toward a single binary.
## Marge API (Account & Configuration)
Base path: `/marge`
@@ -71,7 +71,7 @@ All subsequent messages (except `selectLastWiFiSource`, see below) use this enve
```
`requestID` is a monotonically increasing integer per connection (client-side sequence).
`{device_id}` is the speaker's MAC address with colons removed (e.g. `08DF1F0BA325`).
`{device_id}` is the speaker's MAC address with colons removed (e.g. `AABBCCDDEE0A`).
---
+23 -23
View File
@@ -84,7 +84,7 @@ Device system settings:
$ soundtouch-cli --host 192.0.2.100 analyze
🔍 Device Capability Analysis:
Device ID: 08DF1F0BA325
Device ID: AABBCCDDEE0A
Feature Coverage: 87% (13/15 features)
Device Type: Premium SoundTouch Speaker (Full Feature Set)
@@ -161,27 +161,27 @@ import (
func analyzeDevice(host string) {
// Create client
c := client.NewClient(&client.Config{Host: host})
// Get supported URLs with feature mapping
supportedURLs, err := c.GetSupportedURLs()
if err != nil {
log.Fatal(err)
}
// Get device capabilities overview
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
fmt.Printf("Device supports %d%% of features (%d/%d)\n",
fmt.Printf("Device supports %d%% of features (%d/%d)\n",
completeness, supported, total)
// Check specific capabilities
if supportedURLs.HasMultiroomSupport() {
fmt.Println("✅ Device can create multiroom zones")
}
if supportedURLs.HasAdvancedAudioSupport() {
fmt.Println("✅ Device has advanced audio controls")
}
// Get missing essential features
missing := supportedURLs.GetMissingEssentialFeatures()
if len(missing) > 0 {
@@ -190,13 +190,13 @@ func analyzeDevice(host string) {
fmt.Printf(" • %s\n", feature.Name)
}
}
// Get features by category
featuresByCategory := supportedURLs.GetFeaturesByCategory()
for category, features := range featuresByCategory {
fmt.Printf("%s: %d features available\n", category, len(features))
}
// Check for partial implementations
partial := supportedURLs.GetPartiallyImplementedFeatures()
for _, feature := range partial {
@@ -211,10 +211,10 @@ func analyzeDevice(host string) {
func canDoAdvancedAudio(supportedURLs *models.SupportedURLsResponse) bool {
requiredEndpoints := []string{
"/audiodspcontrols",
"/audioproducttonecontrols",
"/audioproducttonecontrols",
"/audioproductlevelcontrols",
}
for _, endpoint := range requiredEndpoints {
if !supportedURLs.HasURL(endpoint) {
return false
@@ -226,19 +226,19 @@ func canDoAdvancedAudio(supportedURLs *models.SupportedURLsResponse) bool {
// Get device-specific recommendations
func getPersonalizedTips(supportedURLs *models.SupportedURLsResponse) []string {
var tips []string
if supportedURLs.HasURL("/presets") {
tips = append(tips, "Set up presets for your favorite stations")
}
if supportedURLs.HasURL("/setZone") {
tips = append(tips, "Create multiroom zones for whole-home audio")
}
if supportedURLs.HasURL("/search") && supportedURLs.HasURL("/addStation") {
tips = append(tips, "Search and save new radio stations")
}
return tips
}
```
@@ -289,7 +289,7 @@ soundtouch-cli audio level get # Get level controls
```bash
# Basic Playback (Essential)
soundtouch-cli play start # Start playback
soundtouch-cli play stop # Stop playback
soundtouch-cli play stop # Stop playback
soundtouch-cli play pause # Pause playback
soundtouch-cli play now # Get now playing info
@@ -306,7 +306,7 @@ soundtouch-cli key mute # Mute toggle
# Audio Sources
soundtouch-cli source list # List available sources
soundtouch-cli source select --source SPOTIFY # Select Spotify
soundtouch-cli source bluetooth # Select Bluetooth
soundtouch-cli source bluetooth # Select Bluetooth
soundtouch-cli source aux # Select AUX input
# Service Availability
@@ -321,7 +321,7 @@ soundtouch-cli browse tunein # Browse TuneIn content
soundtouch-cli browse pandora --source-account <account> # Browse Pandora
soundtouch-cli browse spotify --source-account <account> # Browse Spotify
# Station Management
# Station Management
soundtouch-cli station search-tunein --query "jazz" # Search TuneIn
soundtouch-cli station search-pandora --query "rock" --source-account <account>
soundtouch-cli station add --source TUNEIN --token <token> --name "Jazz FM"
@@ -354,7 +354,7 @@ soundtouch-cli zone remove --member 192.0.2.103 # Remove from zone
# Quick capability check
soundtouch-cli supported-urls | grep "Feature Coverage"
# Essential features verification
# Essential features verification
soundtouch-cli analyze | grep -A 5 "Missing Essential Features"
# Advanced features check
@@ -368,7 +368,7 @@ soundtouch-cli supported-urls --features | grep "Multiroom"
Based on feature support, devices are automatically classified:
- **Premium SoundTouch Speaker**: Multiroom + Advanced Audio + Full Feature Set
- **Standard SoundTouch Speaker**: Multiroom Capable + Core Features
- **Standard SoundTouch Speaker**: Multiroom Capable + Core Features
- **Basic SoundTouch Speaker**: Streaming + Presets + Core Features
- **Essential SoundTouch Device**: Core Playback Features Only
- **Limited SoundTouch Device**: Minimal Feature Set
@@ -384,7 +384,7 @@ soundtouch-cli supported-urls --features | grep -i "bass control"
# If not listed, device doesn't support bass control
```
**Issue**: "Multiroom not available"
**Issue**: "Multiroom not available"
```bash
# Verify multiroom support
soundtouch-cli analyze | grep "Multiroom"
@@ -405,7 +405,7 @@ soundtouch-cli supported-urls --features | grep "Content Navigation"
The feature mapping system provides personalized recommendations:
- **Missing Balance Control**: "No balance control available on this device"
- **Multiroom Available**: "Create speaker groups with other devices"
- **Multiroom Available**: "Create speaker groups with other devices"
- **Advanced Audio**: "Fine-tune sound with DSP controls"
- **Limited Features**: "Consider upgrading for full functionality"
@@ -417,4 +417,4 @@ The feature mapping system provides personalized recommendations:
4. **Review recommendations** for optimal device usage
5. **Monitor feature completeness** to understand device limitations
This comprehensive feature mapping system ensures you get the most out of your SoundTouch device by understanding exactly what it can do and how to use it effectively.
This comprehensive feature mapping system ensures you get the most out of your SoundTouch device by understanding exactly what it can do and how to use it effectively.
+117 -28
View File
@@ -66,15 +66,15 @@ func main() {
Host: "192.0.2.100",
Port: 8090,
}
client := client.NewClient(config)
// Play TTS at current volume (language code "EN", "DE", etc.)
err := client.PlayTTS("Hello, this is a test message", "YOUR_APP_KEY", "EN")
if err != nil {
log.Fatal(err)
}
// Play TTS at specific volume (70)
err = client.PlayTTS("Volume test message", "YOUR_APP_KEY", "EN", 70)
if err != nil {
@@ -91,9 +91,9 @@ func main() {
Host: "192.0.2.100",
Port: 8090,
}
client := client.NewClient(config)
// Play audio from URL
err := client.PlayURL(
"https://example.com/audio.mp3",
@@ -114,7 +114,7 @@ func main() {
```go
func main() {
client := client.NewClient(config)
// Create custom play info
playInfo := models.NewPlayInfo(
"https://example.com/audio.mp3",
@@ -123,7 +123,7 @@ func main() {
"Custom Message",
"Custom Reason",
).SetVolume(60)
err := client.PlayCustom(playInfo)
if err != nil {
log.Fatal(err)
@@ -136,7 +136,7 @@ func main() {
```go
func main() {
client := client.NewClient(config)
// Uses GET request (fixed in v2025.02+)
err := client.PlayNotificationBeep()
if err != nil {
@@ -206,22 +206,22 @@ soundtouch-cli speaker url --help
The following language codes are supported for Google TTS:
| Code | Language |
|------|----------|
| EN | English |
| DE | German |
| ES | Spanish |
| FR | French |
| IT | Italian |
| NL | Dutch |
| PT | Portuguese |
| RU | Russian |
| ZH | Chinese |
| JA | Japanese |
| KO | Korean |
| AR | Arabic |
| HI | Hindi |
| TH | Thai |
| Code | Language |
|------|------------|
| EN | English |
| DE | German |
| ES | Spanish |
| FR | French |
| IT | Italian |
| NL | Dutch |
| PT | Portuguese |
| RU | Russian |
| ZH | Chinese |
| JA | Japanese |
| KO | Korean |
| AR | Arabic |
| HI | Hindi |
| TH | Thai |
## Behavior Notes
@@ -230,7 +230,7 @@ The following language codes are supported for Google TTS:
- Automatically restore the previous volume after playback completes
- If volume is 0 or omitted, content plays at current volume
2. **Content Interruption**:
2. **Content Interruption**:
- Currently playing content is paused during notification playback
- Original content resumes automatically after notification ends
- If currently playing content is already a notification, you may get an error
@@ -241,7 +241,7 @@ The following language codes are supported for Google TTS:
4. **Now Playing Display**:
- Service name appears in the "artist" field
- Message appears in the "album" field
- Message appears in the "album" field
- Reason appears in the "track" field
- Custom artwork can be included in URL-based content
@@ -264,6 +264,95 @@ Both TTS and URL playback require an `app_key` parameter. This appears to be use
You'll need to provide your own application key. The format and generation method for valid app keys is not documented in the official API.
## Google Cloud Text-to-Speech (via the AfterTouch service)
The direct `speaker tts` path above hands the speaker an (undocumented) Google
Translate URL to fetch. That endpoint is fine for short notifications but is
low quality, length-limited, and can change without notice.
For higher-quality speech (real voices, SSML, many languages) the AfterTouch
service can synthesize audio with **Google Cloud Text-to-Speech** and host it
locally for the speaker to play. Because Cloud TTS returns audio bytes from an
authenticated request (not a fetchable URL), the service caches the clip and
serves it at `GET /media/tts/{id}`, then tells the speaker to play that local
URL via `/speaker`. The same `app_key` constraint applies.
### Provider selection
The service picks a TTS provider via `--tts-provider` (env `TTS_PROVIDER`):
- `translate` (default): the Google Translate URL path. No credentials.
- `google-cloud`: Google Cloud TTS via a REST API key. No OAuth, no SDK.
### Configuration (soundtouch-service)
| Flag | Env | Purpose |
|------------------------|----------------------|---------------------------------------------------------------------------------|
| `--tts-provider` | `TTS_PROVIDER` | `translate` or `google-cloud` |
| `--tts-google-api-key` | `TTS_GOOGLE_API_KEY` | Google Cloud TTS API key (required for `google-cloud`) |
| `--tts-language` | `TTS_LANGUAGE` | Default language. `EN`/`DE` for translate, BCP-47 like `en-US` for google-cloud |
| `--tts-voice` | `TTS_VOICE` | Default Cloud TTS voice (e.g. `en-US-Neural2-C`); ignored by translate |
| `--tts-app-key` | `TTS_APP_KEY` | Bose `/speaker` app_key used to play the clip |
| `--tts-volume` | `TTS_VOLUME` | Default playback volume (0-100, 0 = keep current) |
Example:
```bash
TTS_PROVIDER=google-cloud \
TTS_GOOGLE_API_KEY=YOUR_GOOGLE_API_KEY \
TTS_LANGUAGE=en-US \
TTS_VOICE=en-US-Neural2-C \
TTS_APP_KEY=YOUR_APP_KEY \
soundtouch-service
```
### Triggering speech
Service HTTP API (under `/setup`, LAN-trust like the rest of the setup surface,
no auth):
```bash
curl -X POST http://soundtouch.local:8000/setup/tts/speak \
-H 'Content-Type: application/json' \
-d '{"host":"192.0.2.100","text":"Dinner is ready"}'
```
`deviceId` may be used instead of `host` (the service resolves it to an IP from its datastore). Optional fields: `language`, `voice`, `volume`, and `method`
(`speaker`, the default /speaker notification path that ducks and resumes
playback, or `radio`, the LOCAL_INTERNET_RADIO path that needs no app_key but
replaces the current source).
CLI (`speaker tts-cloud` routes through the service for Cloud TTS, in contrast
to `speaker tts` which sends a Google Translate URL straight to the speaker):
```bash
soundtouch-cli speaker tts-cloud \
--service-url http://soundtouch.local:8000 \
--host 192.0.2.100 \
--text "Dinner is ready" \
--method speaker
```
Web UI: the TTS source view has a "Say something…" box. soundtouch-player proxies
it to the service, so it must be started with `--service-url` (the target is
server-configured, not entered in the browser, to avoid an SSRF proxy).
### Notes and limitations
- **`app_key` validation is handled automatically (speaker method).** The
speaker validates the key by calling `GET /v1/auth` on Bose's audio
notification host (`audionotification.api.bosecm.com`, and a `…dev…` variant),
which AfterTouch intercepts (DNS substring match on `bosecm.com`, plus
`/etc/hosts` seeding during migration) and answers `200`. So any non-empty
`app_key` works; you don't need a real Bose-issued key. The `radio` method
needs no `app_key` at all.
- **Model support** for the `speaker` method is the same as the direct `/speaker`
path (primarily ST-10 Series III). Use `--method radio` on models without it.
- **Reachability:** the speaker must be able to reach the service's
`/media/tts/{id}` URL. The service builds it from its configured `server-url`.
- Synthesized clips are cached in memory for a short time and identical requests
reuse the same clip.
## Limitations
1. **Device Support**: Limited to specific SoundTouch models (primarily ST-10 Series III)
@@ -283,7 +372,7 @@ client.PlayTTS("Someone is at the front door", "home-automation-key", "EN", 80)
// Security alert
client.PlayURL(
"https://myserver.com/alerts/security-breach.mp3",
"security-system-key",
"security-system-key",
"Security System",
"Alert",
"Motion detected in restricted area",
@@ -297,7 +386,7 @@ client.PlayURL(
# Test connectivity
soundtouch-cli speaker beep --host 192.0.2.100
# Test TTS functionality
# Test TTS functionality
soundtouch-cli speaker tts --text "Testing TTS functionality" --app-key test-key --host 192.0.2.100
# Test URL playback

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 383 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+2 -2
View File
@@ -1,8 +1,8 @@
module navigation-station-demo
go 1.26.3
go 1.26.4
require github.com/gesellix/bose-soundtouch v0.93.1
require github.com/gesellix/bose-soundtouch v0.107.0
require github.com/gorilla/websocket v1.5.3 // indirect
+17 -13
View File
@@ -1,3 +1,4 @@
// Package main demonstrates content navigation and station management with SoundTouch devices.
package main
import (
@@ -41,12 +42,14 @@ func main() {
func demonstrateNavigationAndStations(c *client.Client) error {
// 1. Browse TuneIn content
fmt.Println("📻 Step 1: Browsing TuneIn stations...")
if err := browseTuneInStations(c); err != nil {
return fmt.Errorf("failed to browse TuneIn: %w", err)
}
// 2. Search for specific content
fmt.Println("\n🔍 Step 2: Searching for jazz stations...")
searchResults, err := searchForJazzStations(c)
if err != nil {
return fmt.Errorf("failed to search stations: %w", err)
@@ -54,6 +57,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 3. Add and play a station
fmt.Println("\n Step 3: Adding and playing a station...")
if err := addAndPlayStation(c, searchResults); err != nil {
fmt.Printf("⚠️ Could not add station: %v\n", err)
// Continue with demo even if this fails
@@ -61,6 +65,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 4. Demonstrate Pandora search (if account available)
fmt.Println("\n🎵 Step 4: Demonstrating Pandora search...")
if err := demonstratePandoraSearch(c); err != nil {
fmt.Printf("⚠️ Pandora search not available: %v\n", err)
// Continue with demo
@@ -68,6 +73,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 5. Browse stored music (if available)
fmt.Println("\n💿 Step 5: Browsing stored music...")
if err := browseStoredMusic(c); err != nil {
fmt.Printf("⚠️ Stored music not available: %v\n", err)
// Continue with demo
@@ -75,6 +81,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 6. Search Spotify content (if account available)
fmt.Println("\n🎧 Step 6: Demonstrating Spotify search...")
if err := demonstrateSpotifySearch(c); err != nil {
fmt.Printf("⚠️ Spotify search not available: %v\n", err)
// Continue with demo
@@ -95,8 +102,10 @@ func browseTuneInStations(c *client.Client) error {
if len(response.Items) > 0 {
fmt.Printf(" 🎵 Sample stations:\n")
for i, item := range response.Items[:min(5, len(response.Items))] {
fmt.Printf(" %d. %s\n", i+1, item.GetDisplayName())
if item.IsPlayable() {
fmt.Printf(" ▶️ Playable\n")
} else if item.IsDirectory() {
@@ -125,13 +134,16 @@ func searchForJazzStations(c *client.Client) (*models.SearchStationResponse, err
if len(songs) > 0 {
fmt.Printf(" 🎵 Songs (%d): %s\n", len(songs), songs[0].GetDisplayName())
}
if len(artists) > 0 {
fmt.Printf(" 🎤 Artists (%d): %s\n", len(artists), artists[0].GetDisplayName())
}
if len(stations) > 0 {
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
for i, station := range stations[:min(3, len(stations))] {
fmt.Printf(" %d. %s (Token: %s)\n", i+1, station.GetDisplayName(), station.Token)
for i := range stations[:min(3, len(stations))] {
fmt.Printf(" %d. %s (Token: %s)\n", i+1, stations[i].GetDisplayName(), stations[i].Token)
}
}
@@ -176,7 +188,7 @@ func addAndPlayStation(c *client.Client, searchResults *models.SearchStationResp
return nil
}
func demonstratePandoraSearch(c *client.Client) error {
func demonstratePandoraSearch(_ *client.Client) error {
// Note: This would require a valid Pandora account
// For demo purposes, we'll show how it would work
fmt.Printf(" 🎵 Pandora search requires a valid source account\n")
@@ -190,7 +202,7 @@ func demonstratePandoraSearch(c *client.Client) error {
return nil
}
func browseStoredMusic(c *client.Client) error {
func browseStoredMusic(_ *client.Client) error {
// Note: This would require a valid device ID for stored music
fmt.Printf(" 💿 Stored music browsing requires device ID\n")
fmt.Printf(" 💡 Example usage:\n")
@@ -204,7 +216,7 @@ func browseStoredMusic(c *client.Client) error {
return nil
}
func demonstrateSpotifySearch(c *client.Client) error {
func demonstrateSpotifySearch(_ *client.Client) error {
// Note: This would require a valid Spotify account
fmt.Printf(" 🎧 Spotify search requires a valid source account\n")
fmt.Printf(" 💡 Example usage:\n")
@@ -218,14 +230,6 @@ func demonstrateSpotifySearch(c *client.Client) error {
return nil
}
// Helper function to get minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
func printUsage() {
fmt.Println("🎵 SoundTouch Navigation & Station Management Demo")
fmt.Println()
+2 -2
View File
@@ -1,8 +1,8 @@
module preset-management-example
go 1.26.3
go 1.26.4
require github.com/gesellix/bose-soundtouch v0.93.1
require github.com/gesellix/bose-soundtouch v0.107.0
require github.com/gorilla/websocket v1.5.3 // indirect
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/gesellix/bose-soundtouch
go 1.26.3
go 1.26.4
require (
filippo.io/age v1.3.1
+4 -4
View File
@@ -13,7 +13,7 @@ import (
func TestClient_Post_ErrorsResponse(t *testing.T) {
// Mock speaker error response
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<errors deviceID="AABBCCDDEE0A">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
@@ -38,8 +38,8 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
t.Fatalf("expected models.ErrorsResponse, got %T: %v", err, err)
}
if errs.DeviceID != "08DF1F0BA325" {
t.Errorf("expected DeviceID 08DF1F0BA325, got %s", errs.DeviceID)
if errs.DeviceID != "AABBCCDDEE0A" {
t.Errorf("expected DeviceID AABBCCDDEE0A, got %s", errs.DeviceID)
}
if len(errs.Errors) != 1 {
@@ -67,7 +67,7 @@ func TestClient_Post_ErrorsResponse(t *testing.T) {
func TestClient_PostWithResponse_ErrorsResponse(t *testing.T) {
// Mock speaker error response
errorXML := `<?xml version="1.0" encoding="UTF-8" ?>
<errors deviceID="08DF1F0BA325">
<errors deviceID="AABBCCDDEE0A">
<error value="1029" name="UNKNOWN_ACTION_ERROR" severity="Unknown">This version of SCM does not support spotify create account functionality.</error>
</errors>`
+1 -1
View File
@@ -307,7 +307,7 @@ func ExampleClient_GetSupportedURLs_concept() {
}
// Expected output with a real device:
// Device 08DF1F0BA325 supports 103 endpoints
// Device AABBCCDDEE0A supports 103 endpoints
// Core functionality: true
// Multiroom support: true
// Streaming support: true
+2 -2
View File
@@ -22,7 +22,7 @@ func TestClient_GetSupportedURLs(t *testing.T) {
{
name: "successful_supported_urls_retrieval",
responseXML: `<?xml version="1.0" encoding="UTF-8"?>
<supportedURLs deviceID="08DF1F0BA325">
<supportedURLs deviceID="AABBCCDDEE0A">
<URL location="/info" />
<URL location="/capabilities" />
<URL location="/supportedURLs" />
@@ -50,7 +50,7 @@ func TestClient_GetSupportedURLs(t *testing.T) {
<URL location="/bassCapabilities" />
</supportedURLs>`,
expectedError: false,
expectedDeviceID: "08DF1F0BA325",
expectedDeviceID: "AABBCCDDEE0A",
expectedURLCount: 25,
expectedURLs: []string{
"/info", "/capabilities", "/supportedURLs", "/volume", "/bass",
+19 -1
View File
@@ -241,6 +241,15 @@ func (ws *WebSocketClient) connectWithConfig(config *WebSocketConfig) error {
ws.conn = conn
ws.connected = true
// Extend the read deadline on every pong so the connection survives
// quiet periods between speaker events. Without this, the 60-second
// read deadline in readLoop fires reliably after one ping cycle (30 s
// ping interval + 5 s reconnect = ~65 s disconnect loop).
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
// Start background goroutines for connection management
go ws.readLoop(config)
go ws.pingLoop(config)
@@ -554,7 +563,16 @@ func (ws *WebSocketClient) handleEvent(event *models.WebSocketEvent) {
if !hasKnownEvent && handlers.OnUnknownEvent != nil {
handlers.OnUnknownEvent(event)
} else if !hasKnownEvent {
ws.logger.Printf("Received unknown event types: %v", eventTypes)
// Log the actual unmodeled element names (e.g. nowSelectionUpdated)
// rather than an empty list; skip frames that carry no child events.
if names := event.UnknownEventNames(); len(names) > 0 {
sanitizedNames := make([]string, 0, len(names))
for _, name := range names {
sanitizedNames = append(sanitizedNames, sanitizeLog(name))
}
ws.logger.Printf("Received unhandled event types: %v", sanitizedNames)
}
}
}
+43 -8
View File
@@ -29,8 +29,9 @@ type DNSDiscovery struct {
derivedHosts []string
// State
discovered map[string]*DiscoveredHost
mu sync.RWMutex
discovered map[string]*DiscoveredHost
interceptClients map[string]time.Time
mu sync.RWMutex
// Callbacks
onNewDiscovery func(hostname string)
@@ -73,12 +74,13 @@ func NewDNSDiscovery(upstreamDNS []string, serviceIP, serverURL string) *DNSDisc
}
return &DNSDiscovery{
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
derivedHosts: derived,
discovered: make(map[string]*DiscoveredHost),
timeout: 2 * time.Second,
lastLog: make(map[string]time.Time),
upstreamDNS: upstreamDNS,
serviceIP: serviceIP,
derivedHosts: derived,
discovered: make(map[string]*DiscoveredHost),
interceptClients: make(map[string]time.Time),
timeout: 2 * time.Second,
lastLog: make(map[string]time.Time),
}
}
@@ -221,6 +223,21 @@ func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAd
host.RemoteAddr = remoteAddr
}
}
// Track distinct non-loopback clients that queried an intercepted hostname.
// Used by the dns_speaker_usage health check to detect whether any speaker
// actually resolves Bose hostnames through AfterTouch's DNS interceptor.
if isIntercepted && remoteAddr != "" {
clientHost, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
// remoteAddr doesn't parse as host:port; use as-is.
clientHost = remoteAddr
}
if ip := net.ParseIP(clientHost); ip != nil && !ip.IsLoopback() {
d.interceptClients[clientHost] = time.Now()
}
}
}
// InterceptedBoseHosts is the canonical list of Bose cloud service
@@ -434,6 +451,24 @@ func (d *DNSDiscovery) GetDiscovered() map[string]*DiscoveredHost {
return result
}
// InterceptClientIPs returns a copy of the set of non-loopback client IPs
// that have queried an intercepted Bose hostname. The value for each key is
// the timestamp of the most recent intercepted query from that client.
// Empty map means no non-loopback speaker has ever used AfterTouch's DNS
// interceptor. Callers receive a snapshot; the map is safe to read after
// this method returns.
func (d *DNSDiscovery) InterceptClientIPs() map[string]time.Time {
d.mu.RLock()
defer d.mu.RUnlock()
result := make(map[string]time.Time, len(d.interceptClients))
for k, v := range d.interceptClients {
result[k] = v
}
return result
}
// GetBoseHosts returns a slice of all discovered Bose-related hosts.
func (d *DNSDiscovery) GetBoseHosts() []*DiscoveredHost {
d.mu.RLock()
+81
View File
@@ -545,3 +545,84 @@ func TestDNSDiscovery_UnresolvableHostname(t *testing.T) {
t.Errorf("Expected CNAME record for unresolvable hostname, got %T", rw.msg.Answer[0])
}
}
// mockResponseWriterWithAddr is like mockResponseWriter but returns a
// configurable remote address; used to simulate queries from speaker IPs.
type mockResponseWriterWithAddr struct {
mockResponseWriter
remote net.Addr
}
func (m *mockResponseWriterWithAddr) RemoteAddr() net.Addr { return m.remote }
// mockUDPAddr implements net.Addr for test purposes.
type mockUDPAddr struct{ addr string }
func (a *mockUDPAddr) Network() string { return "udp" }
func (a *mockUDPAddr) String() string { return a.addr }
func TestDNSDiscovery_InterceptClientTracking_NonLoopback(t *testing.T) {
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100", "")
// Query for an intercepted Bose hostname from a non-loopback address.
msg := new(dns.Msg)
msg.SetQuestion("content.api.bose.io.", dns.TypeA)
rw := &mockResponseWriterWithAddr{
remote: &mockUDPAddr{addr: "10.1.103.209:54321"},
}
d.ServeDNS(rw, msg)
clients := d.InterceptClientIPs()
if _, ok := clients["10.1.103.209"]; !ok {
t.Errorf("expected 10.1.103.209 in interceptClients, got %v", clients)
}
}
func TestDNSDiscovery_InterceptClientTracking_LoopbackExcluded(t *testing.T) {
d := NewDNSDiscovery([]string{"8.8.8.8"}, "192.0.2.100", "")
// Query for an intercepted hostname from loopback (health-probe scenario).
msg := new(dns.Msg)
msg.SetQuestion("api.bose.com.", dns.TypeA)
rw := &mockResponseWriterWithAddr{
remote: &mockUDPAddr{addr: "127.0.0.1:55069"},
}
d.ServeDNS(rw, msg)
clients := d.InterceptClientIPs()
if len(clients) != 0 {
t.Errorf("expected no clients (loopback should be excluded), got %v", clients)
}
}
func TestDNSDiscovery_InterceptClientTracking_NonInterceptedNotRecorded(t *testing.T) {
// Forwarded (non-intercepted) queries should not populate interceptClients.
upstreamMux := dns.NewServeMux()
upstreamMux.HandleFunc("google.com.", func(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
_ = w.WriteMsg(m)
})
ts := &dns.Server{Addr: "127.0.0.1:5360", Net: "udp", Handler: upstreamMux,
ReadTimeout: 100 * time.Millisecond, WriteTimeout: 100 * time.Millisecond}
go func() { _ = ts.ListenAndServe() }()
defer func() { _ = ts.Shutdown() }()
time.Sleep(100 * time.Millisecond)
d := NewDNSDiscovery([]string{"127.0.0.1:5360"}, "192.0.2.100", "")
msg := new(dns.Msg)
msg.SetQuestion("google.com.", dns.TypeA)
rw := &mockResponseWriterWithAddr{
remote: &mockUDPAddr{addr: "10.1.103.209:54321"},
}
d.ServeDNS(rw, msg)
clients := d.InterceptClientIPs()
if len(clients) != 0 {
t.Errorf("expected no intercept clients for non-intercepted query, got %v", clients)
}
}
+12 -2
View File
@@ -58,14 +58,24 @@ func (p *PlayInfo) SetVolume(volume int) *PlayInfo {
return p
}
// BuildTranslateTTSURL builds the (undocumented) Google Translate TTS URL the
// speaker fetches directly for a /speaker notification. language is a short code
// such as "EN" or "DE". Shared by NewTTSPlayInfo and the TTS service's Translate
// provider so the query-string format lives in exactly one place.
//
// https://translate.google.com/translate_tts?ie=UTF-8&tl=de&client=aftertouch&q=Hallo+Wie+Geht%27s
func BuildTranslateTTSURL(text, language string) string {
return fmt.Sprintf("https://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
}
// NewTTSPlayInfo creates a PlayInfo for Google TTS playback
func NewTTSPlayInfo(text, appKey, language string, volume ...int) *PlayInfo {
// URL encode the text for Google TTS
url := fmt.Sprintf("http://translate.google.com/translate_tts?ie=UTF-8&tl=%s&client=tw-ob&q=%s", language, url.QueryEscape(text))
ttsURL := BuildTranslateTTSURL(text, language)
playInfo := &PlayInfo{
XMLName: xml.Name{Local: "play_info"},
URL: url,
URL: ttsURL,
AppKey: appKey,
Service: "TTS Notification",
Message: "Google TTS",
+1 -1
View File
@@ -37,7 +37,7 @@ func TestNewTTSPlayInfo(t *testing.T) {
// Test without volume
playInfo := NewTTSPlayInfo("Hello World", "test-key", "EN")
expectedURL := "http://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
expectedURL := "https://translate.google.com/translate_tts?ie=UTF-8&tl=EN&client=tw-ob&q=Hello+World"
if playInfo.URL != expectedURL {
t.Errorf("Expected URL '%s', got '%s'", expectedURL, playInfo.URL)
}
+22 -1
View File
@@ -102,7 +102,28 @@ type WebSocketEvent struct {
ErrorUpdated *ErrorUpdatedEvent `xml:"errorUpdated,omitempty"`
RecentsUpdated *RecentsUpdatedEvent `xml:"recentsUpdated,omitempty"`
LanguageUpdated *LanguageUpdatedEvent `xml:"languageUpdated,omitempty"`
Timestamp time.Time `json:"timestamp"` // Added by client for tracking
// UnknownElements captures <updates> children we don't model yet (e.g.
// nowSelectionUpdated), so callers can log them by name instead of an
// empty list when no known event matched.
UnknownElements []UnknownElement `xml:",any"`
Timestamp time.Time `json:"timestamp"` // Added by client for tracking
}
// UnknownElement records the tag name of an <updates> child element that the
// WebSocketEvent struct does not (yet) model.
type UnknownElement struct {
XMLName xml.Name
}
// UnknownEventNames returns the tag names of any unmodeled <updates> children,
// for diagnostic logging.
func (e *WebSocketEvent) UnknownEventNames() []string {
names := make([]string, 0, len(e.UnknownElements))
for _, u := range e.UnknownElements {
names = append(names, u.XMLName.Local)
}
return names
}
// GetEvents returns all events present in this WebSocket event
+38
View File
@@ -540,3 +540,41 @@ func TestCreateMockWebSocketEvent(t *testing.T) {
t.Errorf("Event types don't match expected values")
}
}
// TestParseWebSocketEvent_UnknownElements verifies that an <updates> envelope
// whose only child is an unmodeled element (e.g. nowSelectionUpdated, observed
// on real SoundTouch 10 firmware) parses with no known event types but with
// the element captured by name, so callers can log something useful instead
// of an empty list.
func TestParseWebSocketEvent_UnknownElements(t *testing.T) {
raw := []byte(`<updates deviceID="A81B6A536A98"><nowSelectionUpdated><preset id="0"><ContentItem source="TUNEIN" type="stationurl" location="/v1/playback/station/s308770" sourceAccount="" isPresetable="true"><itemName>Willy</itemName></ContentItem></preset></nowSelectionUpdated></updates>`)
event, err := ParseWebSocketEvent(raw)
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if got := event.GetEventTypes(); len(got) != 0 {
t.Errorf("expected no known event types, got %v", got)
}
names := event.UnknownEventNames()
if len(names) != 1 || names[0] != "nowSelectionUpdated" {
t.Errorf("expected [nowSelectionUpdated], got %v", names)
}
}
// TestParseWebSocketEvent_KnownEventNoUnknowns confirms a modeled event is not
// also captured as an unknown element.
func TestParseWebSocketEvent_KnownEventNoUnknowns(t *testing.T) {
raw := []byte(`<updates deviceID="A81B6A536A98"><nowPlayingUpdated><nowPlaying deviceID="A81B6A536A98" source="TUNEIN"></nowPlaying></nowPlayingUpdated></updates>`)
event, err := ParseWebSocketEvent(raw)
if err != nil {
t.Fatalf("ParseWebSocketEvent: %v", err)
}
if names := event.UnknownEventNames(); len(names) != 0 {
t.Errorf("expected no unknown elements for a modeled event, got %v", names)
}
}
+48 -7
View File
@@ -4,25 +4,61 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
streamList := []models.Stream{
{
// BuildOrionLocation wraps a raw stream URL in the AfterTouch Orion station
// endpoint that the speaker's BMX module expects when playing LOCAL_INTERNET_RADIO
// content. The speaker calls GET on the stored location expecting a
// BmxPlaybackResponse JSON — not raw audio bytes.
func BuildOrionLocation(serviceURL, name, imageURL, streamURL string) string {
payload := struct {
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
StreamURL string `json:"streamUrl"`
}{
Name: name,
ImageURL: imageURL,
StreamURL: streamURL,
}
data, err := json.Marshal(payload)
if err != nil {
return ""
}
encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data))
return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded
}
// BuildCustomStreamResponseFromURLs wraps one or more candidate stream URLs
// in a playback response. The speaker fails over between entries in the
// Streams array, so order matters — pass them as the provider listed them.
// The top-level StreamUrl mirrors urls[0] for compatibility.
func BuildCustomStreamResponseFromURLs(urls []string, imageURL, name string) (*models.BmxPlaybackResponse, error) {
if len(urls) == 0 {
return nil, fmt.Errorf("no stream URLs provided")
}
streamList := make([]models.Stream, 0, len(urls))
for _, u := range urls {
streamList = append(streamList, models.Stream{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: streamURL,
},
StreamUrl: u,
})
}
audio := models.Audio{
HasPlaylist: true,
IsRealtime: true,
StreamUrl: streamURL,
StreamUrl: urls[0],
Streams: streamList,
}
@@ -36,6 +72,11 @@ func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPla
return response, nil
}
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
return BuildCustomStreamResponseFromURLs([]string{streamURL}, imageURL, name)
}
// PlayCustomStream builds a playback response from a base64-encoded JSON blob
// with fields streamUrl, imageUrl, and name.
func PlayCustomStream(data string) (*models.BmxPlaybackResponse, error) {
+43
View File
@@ -29,3 +29,46 @@ func TestPlayCustomStream(t *testing.T) {
t.Errorf("Expected name Stream Name, got %s", resp.Name)
}
}
func TestBuildCustomStreamResponseFromURLs(t *testing.T) {
// Multiple candidates must all reach the speaker, in order, so it can
// fail over from a dead variant to a working one (see s56857 / NDR 2).
urls := []string{
"https://example.com/aac/low",
"https://example.com/mp3/128/stream.mp3",
}
resp, err := BuildCustomStreamResponseFromURLs(urls, "image.png", "NDR 2")
if err != nil {
t.Fatalf("BuildCustomStreamResponseFromURLs failed: %v", err)
}
if got := len(resp.Audio.Streams); got != len(urls) {
t.Fatalf("expected %d streams, got %d", len(urls), got)
}
for i, want := range urls {
if got := resp.Audio.Streams[i].StreamUrl; got != want {
t.Errorf("stream %d: expected %q, got %q", i, want, got)
}
}
if resp.Audio.StreamUrl != urls[0] {
t.Errorf("top-level StreamUrl: expected %q, got %q", urls[0], resp.Audio.StreamUrl)
}
// Empty input is an error, not a panic.
if _, err := BuildCustomStreamResponseFromURLs(nil, "", ""); err == nil {
t.Error("expected error for empty URL list, got nil")
}
// The single-URL wrapper still yields exactly one stream.
single, err := BuildCustomStreamResponse("https://example.com/only", "", "Solo")
if err != nil {
t.Fatalf("BuildCustomStreamResponse failed: %v", err)
}
if got := len(single.Audio.Streams); got != 1 {
t.Errorf("expected 1 stream from single-URL builder, got %d", got)
}
}
+65 -13
View File
@@ -1,6 +1,7 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -11,12 +12,25 @@ import (
var radioBrowserBaseURL = "https://all.api.radio-browser.info"
// RadioBrowserSearch searches for radio stations using the RadioBrowser API.
func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=20&order=clickcount&reverse=true",
radioBrowserBaseURL, url.QueryEscape(query))
const radioBrowserPageSize = 20
resp, err := http.Get(searchURL)
// radioBrowserCursor is the opaque pagination cursor for RadioBrowser search results.
type radioBrowserCursor struct {
Query string `json:"q"`
NextOffset int `json:"o"`
}
// RadioBrowserSearch searches for radio stations using the RadioBrowser API (first page).
func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
return RadioBrowserSearchPage(query, 0)
}
// RadioBrowserSearchPage searches for radio stations at a specific offset.
func RadioBrowserSearchPage(query string, offset int) (*models.BmxNavResponse, error) {
searchURL := fmt.Sprintf("%s/json/stations/search?name=%s&limit=%d&offset=%d&hidebroken=true&order=clickcount&reverse=true",
radioBrowserBaseURL, url.QueryEscape(query), radioBrowserPageSize, offset)
resp, err := http.Get(searchURL) //nolint:noctx
if err != nil {
return nil, err
}
@@ -32,13 +46,9 @@ func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
return nil, err
}
navResp := &models.BmxNavResponse{
BmxSections: []models.BmxNavSection{
{
Name: "Stations",
Items: make([]models.BmxNavItem, 0, len(stations)),
},
},
section := models.BmxNavSection{
Name: "Stations",
Items: make([]models.BmxNavItem, 0, len(stations)),
}
for _, station := range stations {
@@ -71,8 +81,50 @@ func RadioBrowserSearch(query string) (*models.BmxNavResponse, error) {
},
},
}
navResp.BmxSections[0].Items = append(navResp.BmxSections[0].Items, item)
section.Items = append(section.Items, item)
}
// Attach a BmxNext link only when the page is full (more results likely exist).
if len(stations) == radioBrowserPageSize {
cursorData := radioBrowserCursor{Query: query, NextOffset: offset + radioBrowserPageSize}
cursorJSON, err := json.Marshal(cursorData)
if err == nil {
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
section.Links = &models.Links{
BmxNext: &models.Link{Href: "/v1/radiobrowser/search/next?cursor=" + encoded},
}
}
}
navResp := &models.BmxNavResponse{
BmxSections: []models.BmxNavSection{section},
}
return navResp, nil
}
// RadioBrowserSearchNext fetches the next page of RadioBrowser search results using the
// opaque cursor produced by RadioBrowserSearchPage.
func RadioBrowserSearchNext(encodedCursor string) (*models.BmxNavResponse, error) {
cursorBytes, err := base64.RawURLEncoding.DecodeString(encodedCursor)
if err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
var cursor radioBrowserCursor
if err := json.Unmarshal(cursorBytes, &cursor); err != nil {
return nil, fmt.Errorf("invalid cursor: %w", err)
}
if cursor.Query == "" {
return nil, fmt.Errorf("invalid cursor: missing query")
}
if cursor.NextOffset < 0 {
return nil, fmt.Errorf("invalid cursor: negative offset")
}
return RadioBrowserSearchPage(cursor.Query, cursor.NextOffset)
}
+137
View File
@@ -1,10 +1,13 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
@@ -44,6 +47,140 @@ func TestRadioBrowserSearch(t *testing.T) {
}
}
// makeStationsJSON returns a JSON array of n station objects.
func makeStationsJSON(n int) string {
stations := make([]string, n)
for i := 0; i < n; i++ {
stations[i] = fmt.Sprintf(`{"name":"Station %d","stationuuid":"uuid-%d","favicon":"","country":"DE","tags":"pop"}`, i, i)
}
return "[" + strings.Join(stations, ",") + "]"
}
func TestRadioBrowserSearchPage_FullPage_HasNext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, makeStationsJSON(radioBrowserPageSize))
}))
defer ts.Close()
originalBaseURL := radioBrowserBaseURL
radioBrowserBaseURL = ts.URL
defer func() { radioBrowserBaseURL = originalBaseURL }()
resp, err := RadioBrowserSearchPage("test", 0)
if err != nil {
t.Fatalf("RadioBrowserSearchPage failed: %v", err)
}
if len(resp.BmxSections) == 0 {
t.Fatal("expected sections")
}
section := resp.BmxSections[0]
if len(section.Items) != radioBrowserPageSize {
t.Errorf("expected %d items, got %d", radioBrowserPageSize, len(section.Items))
}
if section.Links == nil || section.Links.BmxNext == nil {
t.Fatal("expected BmxNext link on full page")
}
if !strings.Contains(section.Links.BmxNext.Href, "cursor=") {
t.Errorf("expected cursor in BmxNext href, got %q", section.Links.BmxNext.Href)
}
}
func TestRadioBrowserSearchPage_ShortPage_NoNext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, makeStationsJSON(5)) // fewer than radioBrowserPageSize
}))
defer ts.Close()
originalBaseURL := radioBrowserBaseURL
radioBrowserBaseURL = ts.URL
defer func() { radioBrowserBaseURL = originalBaseURL }()
resp, err := RadioBrowserSearchPage("test", 0)
if err != nil {
t.Fatalf("RadioBrowserSearchPage failed: %v", err)
}
if len(resp.BmxSections) == 0 {
t.Fatal("expected sections")
}
section := resp.BmxSections[0]
if section.Links != nil && section.Links.BmxNext != nil {
t.Errorf("expected no BmxNext link on short page, got %q", section.Links.BmxNext.Href)
}
}
func TestRadioBrowserSearchNext_CursorRoundTrip(t *testing.T) {
// Build a cursor manually to verify RadioBrowserSearchNext decodes it correctly.
cursorData := radioBrowserCursor{Query: "jazz", NextOffset: 20}
cursorJSON, err := json.Marshal(cursorData)
if err != nil {
t.Fatalf("marshal cursor: %v", err)
}
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify the offset was forwarded in the URL.
if !strings.Contains(r.URL.RawQuery, "offset=20") {
t.Errorf("expected offset=20 in query, got %q", r.URL.RawQuery)
}
if !strings.Contains(r.URL.RawQuery, "name=jazz") {
t.Errorf("expected name=jazz in query, got %q", r.URL.RawQuery)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, makeStationsJSON(3))
}))
defer ts.Close()
originalBaseURL := radioBrowserBaseURL
radioBrowserBaseURL = ts.URL
defer func() { radioBrowserBaseURL = originalBaseURL }()
resp, err := RadioBrowserSearchNext(encoded)
if err != nil {
t.Fatalf("RadioBrowserSearchNext failed: %v", err)
}
if len(resp.BmxSections) == 0 || len(resp.BmxSections[0].Items) != 3 {
t.Errorf("expected 3 items, got response: %+v", resp)
}
}
func TestRadioBrowserSearchNext_InvalidCursor(t *testing.T) {
_, err := RadioBrowserSearchNext("not-valid-base64!!!")
if err == nil {
t.Error("expected error for invalid cursor")
}
}
func TestRadioBrowserSearchNext_EmptyQueryCursor(t *testing.T) {
// A cursor with an empty query should be rejected.
cursorData := radioBrowserCursor{Query: "", NextOffset: 20}
cursorJSON, err := json.Marshal(cursorData)
if err != nil {
t.Fatalf("marshal cursor: %v", err)
}
encoded := base64.RawURLEncoding.EncodeToString(cursorJSON)
_, err = RadioBrowserSearchNext(encoded)
if err == nil {
t.Error("expected error for cursor with empty query")
}
}
func TestRadioBrowserSearch_Real(t *testing.T) {
if os.Getenv("RADIOBROWSER_INTEGRATION") == "" {
t.Skip("skipping live network test; set RADIOBROWSER_INTEGRATION=1 to run")
+52 -22
View File
@@ -13,23 +13,10 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// TuneIn endpoint templates used to resolve station and stream URLs.
// TuneIn endpoint constants. The base URLs themselves are configurable vars
// (see tuneInOpmlTuneBase and friends below, set via SetTuneInEndpoints); only
// the format-list default is a fixed constant.
const (
TuneInDescribe = "https://opml.radiotime.com/describe.ashx?id=%s"
TuneInNavigateAshx = "http://opml.radiotime.com/?render=json"
TuneInSearchAPI = "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query="
// TuneInProfileContents is the modern JSON API that lists a
// program's (`p<N>`) episodes. The legacy OPML endpoints can't —
// `Tune.ashx?id=p<N>` returns `#STATUS: 400`, `Browse.ashx?id=p<N>`
// only surfaces related genres + networks. Same payload is served
// from api.tunein.com and api.radiotime.com; we use radiotime
// because TuneInNavigateProfile already navigates there via
// Pivots.Contents.Url, so all program-related traffic stays on the
// same host that's already in allowedTuneInHosts. See
// `_/i226/tunein-api-findings.md` for the full endpoint map.
TuneInProfileContents = "https://api.radiotime.com/profiles/%s/contents?version=1.3"
// DefaultTuneInStreamFormats is the comma-separated format list
// AfterTouch sends to TuneIn's Tune.ashx by default. Matches the
// pre-2026-05-10 behaviour from before PR #249 added "hls"
@@ -53,7 +40,7 @@ func TuneInStream(stationID, formats string) string {
formats = DefaultTuneInStreamFormats
}
return fmt.Sprintf("http://opml.radiotime.com/Tune.ashx?id=%s&formats=%s", stationID, formats)
return fmt.Sprintf("%s/Tune.ashx?id=%s&formats=%s", tuneInOpmlTuneBase, stationID, formats)
}
// allowedTuneInHosts restricts outbound fetches to known TuneIn domains.
@@ -62,6 +49,45 @@ var allowedTuneInHosts = map[string]bool{
"api.radiotime.com": true,
}
// TuneIn endpoint base URLs. They default to the real TuneIn hosts (matching the
// constants above) but can be redirected with SetTuneInEndpoints, e.g. to point
// the playback / describe / search calls at a local mock so an integration suite
// does not depend on the live TuneIn service. opmlBase covers the
// opml.radiotime.com endpoints (Tune.ashx, describe.ashx, navigate); apiBase
// covers the api.radiotime.com endpoints (search, profile contents).
var (
tuneInOpmlTuneBase = "http://opml.radiotime.com"
tuneInOpmlDescribeBase = "https://opml.radiotime.com"
tuneInOpmlNavigateBase = "http://opml.radiotime.com"
tuneInAPIBase = "https://api.radiotime.com"
)
// SetTuneInEndpoints overrides the TuneIn upstream base URLs and registers their
// hosts in the outbound allowlist. Empty arguments leave the corresponding
// default in place. Intended for tests and local mocks; production leaves the
// real TuneIn hosts.
func SetTuneInEndpoints(opmlBase, apiBase string) {
if opmlBase != "" {
b := strings.TrimRight(opmlBase, "/")
tuneInOpmlTuneBase = b
tuneInOpmlDescribeBase = b
tuneInOpmlNavigateBase = b
if u, err := url.Parse(b); err == nil && u.Hostname() != "" {
allowedTuneInHosts[u.Hostname()] = true
}
}
if apiBase != "" {
b := strings.TrimRight(apiBase, "/")
tuneInAPIBase = b
if u, err := url.Parse(b); err == nil && u.Hostname() != "" {
allowedTuneInHosts[u.Hostname()] = true
}
}
}
// isTuneInOpmlURI returns true when the URL's host is opml.radiotime.com,
// used to select the OPML/ashx parser over the JSON API parser.
func isTuneInOpmlURI(rawURL string) bool {
@@ -94,7 +120,7 @@ func tuneInRenderJSONURI(rawURL string) string {
// tuneInSearchURI returns the TuneIn search API URL with the query properly URL-encoded.
func tuneInSearchURI(query string) string {
return TuneInSearchAPI + url.QueryEscape(query)
return tuneInAPIBase + "/profiles?fulltextsearch=true&version=1.3&query=" + url.QueryEscape(query)
}
func fetchJSON(fetchURL string) (map[string]interface{}, error) {
@@ -117,7 +143,7 @@ func TuneInNavigate(encodedURI string, subsection *int) (*models.BmxNavResponse,
tuneInURI = decoded
} else {
tuneInURI = TuneInNavigateAshx
tuneInURI = tuneInOpmlNavigateBase + "/?render=json"
templated := true
bmxSearchLink = &models.Link{
Filters: []interface{}{},
@@ -725,7 +751,11 @@ func parseTuneInProgramContents(body []byte, programID string) (episodeID string
}
func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err error) {
fetchURL := fmt.Sprintf(TuneInProfileContents, programID)
// The modern JSON API lists a program's (`p<N>`) episodes; the legacy OPML
// endpoints can't (`Tune.ashx?id=p<N>` returns `#STATUS: 400`). We use the
// api.radiotime.com host (tuneInAPIBase) because TuneInNavigateProfile already
// navigates there. See `_/i226/tunein-api-findings.md` for the endpoint map.
fetchURL := fmt.Sprintf("%s/profiles/%s/contents?version=1.3", tuneInAPIBase, programID)
resp, err := defaultClient.Get(fetchURL)
if err != nil {
@@ -748,7 +778,7 @@ func resolveTuneInProgramLatestEpisode(programID string) (episodeID string, err
// TuneInDescribeMeta fetches the name and logo for a TuneIn guide ID.
func TuneInDescribeMeta(id string) (name, logo string, err error) {
fetchURL := fmt.Sprintf(TuneInDescribe, id)
fetchURL := fmt.Sprintf("%s/describe.ashx?id=%s", tuneInOpmlDescribeBase, id)
resp, err := defaultClient.Get(fetchURL)
if err != nil {
@@ -808,7 +838,7 @@ func TuneInPlayback(stationID, formats string) (*models.BmxPlaybackResponse, err
name, logo, _ := TuneInDescribeMeta(stationID)
return BuildCustomStreamResponse(urls[0], logo, name)
return BuildCustomStreamResponseFromURLs(urls, logo, name)
}
// TuneInPodcastInfo returns info for a TuneIn podcast.
+3 -1
View File
@@ -91,7 +91,9 @@ func TestTuneInSearchURI(t *testing.T) {
{
name: "plain query is appended to base URL",
query: "jazz",
check: func(u string) bool { return u == TuneInSearchAPI+"jazz" },
check: func(u string) bool {
return u == "https://api.radiotime.com/profiles?fulltextsearch=true&version=1.3&query=jazz"
},
},
}
@@ -0,0 +1,52 @@
package datastore
import (
"os"
"path/filepath"
"testing"
)
// TestAtomicWriteFile_DurableRoundTrip guards the durable write path added for
// #458: content must round-trip, an overwrite must truncate cleanly, and no
// `.tmp` sidecar may be left behind. (The fsync durability itself isn't
// unit-testable without power-loss fault injection; this is the functional
// regression guard so the fsync rework doesn't break writes.)
func TestAtomicWriteFile_DurableRoundTrip(t *testing.T) {
tempDir, err := os.MkdirTemp("", "st-atomic-test-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
ds := NewDataStore(tempDir)
dir := ds.AccountDeviceDir("1234567", "001122334455")
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "Sources.xml")
if err := ds.atomicWriteFile(path, []byte("first")); err != nil {
t.Fatalf("atomicWriteFile (create) failed: %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "first" {
t.Errorf("content after create = %q, want %q", got, "first")
}
// Overwrite must truncate the previous (longer) content, not leave a tail.
if err := ds.atomicWriteFile(path, []byte("hi")); err != nil {
t.Fatalf("atomicWriteFile (overwrite) failed: %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "hi" {
t.Errorf("content after overwrite = %q, want %q", got, "hi")
}
// No leftover temp sidecar.
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Errorf("expected no %s.tmp leftover, stat err = %v", path, err)
}
}
+178 -12
View File
@@ -962,6 +962,16 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
return nil, false, err
}
// An empty / 0-byte Presets.xml (e.g. truncated by an unclean power-cut on
// the speaker's NAND) is treated as "no presets" rather than a hard parse
// error, so the device-level /presets endpoint returns an empty list
// instead of HTTP 500. See #458.
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] readPresetsLocked: empty/0-byte Presets.xml at %s — treating as no presets (#458)", sanitizeLog(path))
return []models.ServicePreset{}, false, nil
}
var presetsWrap struct {
Presets []struct {
ID string `xml:"id,attr"`
@@ -989,7 +999,9 @@ func (ds *DataStore) readPresetsLocked(account, device string) ([]models.Service
needsRewrite := !bytes.Equal(normalized, data)
if err := xml.Unmarshal(normalized, &presetsWrap); err != nil {
return nil, false, fmt.Errorf("malformed presets XML at %s: %w", path, err)
log.Printf("[Datastore] readPresetsLocked: malformed Presets.xml at %s (%s) — treating as no presets (#458)", sanitizeLog(path), sanitizeErr(err))
return []models.ServicePreset{}, false, nil
}
presets := []models.ServicePreset{}
@@ -1198,15 +1210,83 @@ func (ds *DataStore) SavePresets(account, device string, presets []models.Servic
return ds.atomicWriteFile(path, append(header, data...))
}
// atomicWriteFile writes data to filename atomically AND durably: it writes a
// temp file, fsyncs it, renames it into place, then fsyncs the parent
// directory. Without the fsyncs an unclean power-cut on a journaling NAND
// filesystem (UBIFS, the speaker's /mnt/nv) can leave the renamed file present
// but 0 bytes — the rename was journalled but the data blocks were never
// flushed. See #458.
func (ds *DataStore) atomicWriteFile(filename string, data []byte) error {
perm := os.FileMode(0644)
tempFile := filename + ".tmp"
if err := ds.rootWriteFile(tempFile, data, perm); err != nil {
if err := ds.rootWriteFileSync(tempFile, data, perm); err != nil {
return err
}
return ds.rootRename(tempFile, filename)
if err := ds.rootRename(tempFile, filename); err != nil {
return err
}
// Fsync the parent directory so the rename itself survives a power-cut.
// Best-effort: not every filesystem permits directory fsync, and the data +
// rename have already succeeded by this point.
ds.rootSyncDir(filepath.Dir(filename))
return nil
}
// rootWriteFileSync writes data to absPath (truncating any existing file) and
// fsyncs the file before returning, so the contents are on stable storage. This
// is the durable equivalent of rootWriteFile.
func (ds *DataStore) rootWriteFileSync(absPath string, data []byte, perm os.FileMode) error {
r, err := ds.getRoot()
if err != nil {
return err
}
rel, err := ds.rootRel(absPath)
if err != nil {
return err
}
f, err := r.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, werr := f.Write(data); werr != nil {
_ = f.Close()
return werr
}
if serr := f.Sync(); serr != nil {
_ = f.Close()
return serr
}
return f.Close()
}
// rootSyncDir fsyncs the directory at absDir so a preceding create/rename is
// durable. Best-effort: directory fsync isn't supported on every filesystem, so
// failures are logged and swallowed rather than failing an already-successful
// write.
func (ds *DataStore) rootSyncDir(absDir string) {
d, err := ds.rootOpen(absDir)
if err != nil {
log.Printf("[Datastore] rootSyncDir: open %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(err))
return
}
if serr := d.Sync(); serr != nil {
log.Printf("[Datastore] rootSyncDir: fsync %s failed (best-effort): %s", sanitizeLog(absDir), sanitizeErr(serr))
}
_ = d.Close()
}
// GetRecents returns the list of recently played items for the specified account and device.
@@ -1225,6 +1305,16 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
return nil, err
}
// An empty / 0-byte Recents.xml (e.g. truncated by an unclean power-cut) is
// treated as "no recents" rather than a hard parse error, so the
// device-level /recents endpoint returns an empty list instead of HTTP 500.
// See #458.
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] GetRecents: empty/0-byte Recents.xml at %s — treating as no recents (#458)", sanitizeLog(path))
return []models.ServiceRecent{}, nil
}
type RecentXML struct {
DeviceID string `xml:"deviceID,attr,omitempty"`
UtcTime string `xml:"utcTime,attr,omitempty"`
@@ -1252,7 +1342,9 @@ func (ds *DataStore) GetRecents(account, device string) ([]models.ServiceRecent,
var wrap RecentsXML
if err := xml.Unmarshal(data, &wrap); err != nil {
return nil, fmt.Errorf("malformed recents XML at %s: %w", path, err)
log.Printf("[Datastore] GetRecents: malformed Recents.xml at %s (%s) — treating as no recents (#458)", sanitizeLog(path), sanitizeErr(err))
return []models.ServiceRecent{}, nil
}
recents := make([]models.ServiceRecent, 0, len(wrap.Recents))
@@ -1794,18 +1886,34 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
// defaultSources is the fallback used whenever there is no usable
// Sources.xml: file missing (normal for a fresh device) or present but
// empty / 0-byte / unparseable. The latter happens when an unclean
// power-cut truncates a not-yet-flushed datastore write on the speaker's
// NAND; treating it like "missing" lets /full re-serve the managed defaults
// so the speaker self-heals instead of dropping all its sources. See #458.
defaultSources := func() []models.ConfiguredSource {
sources := ds.getInitialSources()
ds.DeduceSourceIDs(account, device, sources)
return sources
}
data, err := ds.rootReadFile(path)
if err != nil {
if os.IsNotExist(err) {
sources := ds.getInitialSources()
ds.DeduceSourceIDs(account, device, sources)
return sources, nil
return defaultSources(), nil
}
return nil, err
}
if len(bytes.TrimSpace(data)) == 0 {
log.Printf("[Datastore] GetConfiguredSources: empty/0-byte Sources.xml at %s — treating as missing, serving defaults (#458)", sanitizeLog(path))
return defaultSources(), nil
}
type persistentSource struct {
DisplayName string `xml:"displayName,attr,omitempty"`
ID string `xml:"id,attr,omitempty"`
@@ -1830,7 +1938,9 @@ func (ds *DataStore) GetConfiguredSources(account, device string) ([]models.Conf
}
if err := xml.Unmarshal(data, &sourcesWrap); err != nil {
return nil, fmt.Errorf("malformed sources XML at %s: %w", path, err)
log.Printf("[Datastore] GetConfiguredSources: malformed Sources.xml at %s (%s) — treating as missing, serving defaults (#458)", sanitizeLog(path), sanitizeErr(err))
return defaultSources(), nil
}
sources := make([]models.ConfiguredSource, len(sourcesWrap.Sources))
@@ -2359,12 +2469,22 @@ func (ds *DataStore) GetETagForPresets(account, device string) int64 {
return info.ModTime().UnixNano() / int64(time.Millisecond)
}
// HasConfiguredSources reports whether a Sources.xml file exists for the given account and device.
// HasConfiguredSources reports whether a non-empty Sources.xml file exists for
// the given account and device. A present-but-0-byte file (truncated by an
// unclean power-cut) counts as absent. See #458.
func (ds *DataStore) HasConfiguredSources(account, device string) bool {
path := filepath.Join(ds.AccountDeviceDir(account, device), constants.SourcesFile)
_, err := ds.rootStat(path)
return err == nil
data, err := ds.rootReadFile(path)
if err != nil {
return false
}
// An existing but empty / 0-byte Sources.xml (e.g. truncated by an unclean
// power-cut on the speaker's NAND) must not count as "present": otherwise it
// hides the sources_xml_present health check and its create_default_sources
// quick fix, leaving the device with no managed sources. See #458.
return len(bytes.TrimSpace(data)) > 0
}
// GetETagForSources returns the ETag (modification time) for the sources file for a specific device.
@@ -2464,6 +2584,12 @@ type Settings struct {
AmazonClientID string `json:"amazon_client_id,omitempty"`
AmazonClientSecret string `json:"amazon_client_secret,omitempty"`
AmazonRedirectURI string `json:"amazon_redirect_uri,omitempty"`
TTSProvider string `json:"tts_provider,omitempty"`
TTSGoogleAPIKey string `json:"tts_google_api_key,omitempty"`
TTSAppKey string `json:"tts_app_key,omitempty"`
TTSLanguage string `json:"tts_language,omitempty"`
TTSVoice string `json:"tts_voice,omitempty"`
TTSVolume int `json:"tts_volume,omitempty"`
// TrustForwardedHeaders enables proxy-aware client IP resolution: when the
// immediate TCP peer is one of the TrustedProxyCIDRs, the X-Real-IP /
@@ -2501,6 +2627,15 @@ type Settings struct {
// is passed through verbatim; AfterTouch does not validate the
// individual format tokens.
TuneInStreamFormats string `json:"tunein_stream_formats,omitempty"`
// DefaultLanding selects what the root path "/" serves to a browser:
// "chooser" (or empty) — the neutral landing page that links to the
// player and the admin/setup console;
// "app" — redirect straight to the player UI (/app);
// "admin" — redirect straight to the admin console (/admin).
// API/speaker clients (non-HTML Accept) always get the version JSON
// regardless of this setting.
DefaultLanding string `json:"default_landing,omitempty"`
}
// GetSettings retrieves the global service settings.
@@ -2816,6 +2951,37 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
return err
}
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
// ID) during stereo-pair teardown; since master and slave may live in
// different accounts each speaker deletes its own copy. Returns nil if no
// group files are found — idempotent by design.
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
dir := ds.AccountDevicesDir(account)
entries, err := ds.rootReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil // nothing to delete
}
return err
}
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
_ = ds.rootRemove(filepath.Join(dir, e.Name()))
}
return nil
}
// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
// File presence indicates the station is a favorite; no content is stored.
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
+1 -1
View File
@@ -175,7 +175,7 @@ func TestListAllDevices(t *testing.T) {
ds := NewDataStore(tempDir)
account := "default"
deviceID := "BO5EBO5E-F00D-F00D-FEED-08DF1F0BA325"
deviceID := "BO5EBO5E-F00D-F00D-FEED-AABBCCDDEE0A"
info := &models.ServiceDeviceInfo{
DeviceID: deviceID,
@@ -0,0 +1,135 @@
package datastore
import (
"os"
"path/filepath"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
)
// These tests cover #458: an unclean power-cut on the speaker's NAND can leave
// a datastore file present but 0-byte (truncated, not-yet-flushed write). The
// read paths must treat empty / 0-byte / unparseable files the same as
// "missing" — serve defaults for sources, return empty lists for presets/recents
// — instead of advertising nothing on /full (which wipes the speaker) or
// returning HTTP 500 on the device-level endpoints.
func newTestStore(t *testing.T) (*DataStore, string, string) {
t.Helper()
tempDir, err := os.MkdirTemp("", "st-empty-test-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(tempDir) })
ds := NewDataStore(tempDir)
account := "1234567"
device := "001122334455"
dir := ds.AccountDeviceDir(account, device)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
return ds, account, device
}
func writeDeviceFile(t *testing.T, ds *DataStore, account, device, name string, content []byte) {
t.Helper()
path := filepath.Join(ds.AccountDeviceDir(account, device), name)
if err := os.WriteFile(path, content, 0644); err != nil {
t.Fatal(err)
}
}
func TestGetConfiguredSources_EmptyFile_ServesDefaults(t *testing.T) {
ds, account, device := newTestStore(t)
writeDeviceFile(t, ds, account, device, constants.SourcesFile, []byte{})
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources returned error for 0-byte file: %v", err)
}
if len(sources) == 0 {
t.Fatal("expected default sources for a 0-byte Sources.xml, got none")
}
types := map[string]bool{}
for i := range sources {
types[sources[i].SourceKeyType] = true
}
for _, want := range []string{constants.ProviderTunein, constants.ProviderLocalInternetRadio} {
if !types[want] {
t.Errorf("expected default sources to include %q (ding/radio need it); got %v", want, types)
}
}
}
func TestGetConfiguredSources_MalformedFile_ServesDefaults(t *testing.T) {
ds, account, device := newTestStore(t)
writeDeviceFile(t, ds, account, device, constants.SourcesFile, []byte("<sources><not-closed"))
sources, err := ds.GetConfiguredSources(account, device)
if err != nil {
t.Fatalf("GetConfiguredSources returned error for malformed file: %v", err)
}
if len(sources) == 0 {
t.Fatal("expected default sources for a malformed Sources.xml, got none")
}
}
func TestGetPresets_EmptyFile_NoError(t *testing.T) {
ds, account, device := newTestStore(t)
writeDeviceFile(t, ds, account, device, constants.PresetsFile, []byte{})
presets, err := ds.GetPresets(account, device)
if err != nil {
t.Fatalf("GetPresets returned error for 0-byte file (would surface as HTTP 500): %v", err)
}
if len(presets) != 0 {
t.Errorf("expected no presets for a 0-byte Presets.xml, got %d", len(presets))
}
}
func TestGetRecents_EmptyFile_NoError(t *testing.T) {
ds, account, device := newTestStore(t)
writeDeviceFile(t, ds, account, device, constants.RecentsFile, []byte{})
recents, err := ds.GetRecents(account, device)
if err != nil {
t.Fatalf("GetRecents returned error for 0-byte file (would surface as HTTP 500): %v", err)
}
if len(recents) != 0 {
t.Errorf("expected no recents for a 0-byte Recents.xml, got %d", len(recents))
}
}
func TestHasConfiguredSources_EmptyFile_False(t *testing.T) {
ds, account, device := newTestStore(t)
// 0-byte file present must NOT count as "has sources" — otherwise the
// sources_xml_present health check stays green and hides the
// create_default_sources quick fix.
writeDeviceFile(t, ds, account, device, constants.SourcesFile, []byte{})
if ds.HasConfiguredSources(account, device) {
t.Error("HasConfiguredSources returned true for a 0-byte Sources.xml; want false")
}
// A populated file must still count as present.
writeDeviceFile(t, ds, account, device, constants.SourcesFile,
[]byte(`<?xml version="1.0"?><sources><source><sourceKey type="TUNEIN" account=""/></source></sources>`))
if !ds.HasConfiguredSources(account, device) {
t.Error("HasConfiguredSources returned false for a populated Sources.xml; want true")
}
}
+48 -9
View File
@@ -52,21 +52,33 @@ type Options struct {
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
// Repeat is the total number of times the complete ding is played.
// Speakers need a moment to start buffering after receiving a
// ContentItem, so the first repetition may be missed; later ones
// will be heard. Default 3.
Repeat int
// RepeatGapDuration is the silence inserted between successive
// repetitions, in seconds. Default 0.40.
RepeatGapDuration float64
}
// DefaultOptions returns the canonical option set used by the
// runtime handler when no overrides are supplied.
func DefaultOptions() Options {
return Options{
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
Repeat: 3,
RepeatGapDuration: 0.40,
}
}
@@ -115,6 +127,14 @@ func (o Options) WithDefaults() Options {
o.Peak = d.Peak
}
if o.Repeat <= 0 {
o.Repeat = d.Repeat
}
if o.RepeatGapDuration <= 0 {
o.RepeatGapDuration = d.RepeatGapDuration
}
return o
}
@@ -147,6 +167,25 @@ func Render(opts Options) []byte {
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
// Repeat: append silence + a copy of the base audio for each
// additional repetition. Speakers need a moment to start buffering
// after receiving a ContentItem; repeating ensures at least one
// instance is audible even if the first is missed.
if opts.Repeat > 1 {
repeatGapN := int(math.Round(float64(opts.SampleRate) * opts.RepeatGapDuration))
baseLeft := append([]float64{}, left...)
baseRight := append([]float64{}, right...)
silence := make([]float64, repeatGapN)
for i := 1; i < opts.Repeat; i++ {
left = append(left, silence...)
right = append(right, silence...)
left = append(left, baseLeft...)
right = append(right, baseRight...)
}
}
normalise(left, right, opts.Peak)
var buf bytes.Buffer
+14 -5
View File
@@ -45,13 +45,13 @@ func TestRender_ProducesWAVHeader(t *testing.T) {
}
}
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
func TestRender_DefaultSizeApproximately229KB(t *testing.T) {
data := Render(DefaultOptions())
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
// + ~44 byte header.
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+200 {
// Default: 3 repetitions of 0.6 s + 2 gaps of 0.4 s = 2.6 s total.
// 22050 Hz * 2 ch * 2 bytes * 2.6 s ≈ 229320 data bytes + 44 byte header.
const wantData = 22050 * 2 * 2 * 260 / 100 // 2.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+500 {
t.Errorf("expected ~%d bytes, got %d", wantData, got)
}
}
@@ -112,6 +112,15 @@ func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
}
}
func TestRender_RepeatProducesLongerAudio(t *testing.T) {
once := Render(Options{Repeat: 1}.WithDefaults())
thrice := Render(Options{Repeat: 3}.WithDefaults())
if len(thrice) <= len(once) {
t.Errorf("expected Repeat:3 to produce more bytes than Repeat:1: %d vs %d", len(thrice), len(once))
}
}
func TestWithDefaults_FillsZeroFields(t *testing.T) {
got := Options{PitchHigh: 1000}.WithDefaults()
if got.PitchHigh != 1000 {
+276
View File
@@ -0,0 +1,276 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
const (
defaultAuthProbeTTL = 30 * time.Second
defaultAuthProbeTimeout = 8 * time.Second
authProbePollInterval = 200 * time.Millisecond
)
// authProbe holds the state for a single active DNS-path probe.
type authProbe struct {
Nonce string
DeviceID string
TargetIP string // datastore IP the notification was sent to
CreatedAt time.Time
ExpiresAt time.Time
ObservedFrom string // source IP of the /v1/auth callback (set on hit)
ObservedAt time.Time // zero until observed
}
// authProbeRegistry is an in-memory, mutex-guarded registry of active probes.
// Expired entries are pruned opportunistically on each write operation.
type authProbeRegistry struct {
mu sync.Mutex
active map[string]*authProbe // nonce -> probe
ttl time.Duration // default defaultAuthProbeTTL; injectable for tests
}
// newAuthProbeRegistry creates a new registry with the given ttl.
func newAuthProbeRegistry(ttl time.Duration) *authProbeRegistry {
if ttl <= 0 {
ttl = defaultAuthProbeTTL
}
return &authProbeRegistry{
active: make(map[string]*authProbe),
ttl: ttl,
}
}
// pruneExpired removes expired entries. Caller must hold r.mu.
func (r *authProbeRegistry) pruneExpired() {
now := time.Now()
for nonce, p := range r.active {
if now.After(p.ExpiresAt) {
delete(r.active, nonce)
}
}
}
// register stores a new probe. Prunes expired entries first.
func (r *authProbeRegistry) register(nonce, deviceID, targetIP string) {
now := time.Now()
p := &authProbe{
Nonce: nonce,
DeviceID: deviceID,
TargetIP: targetIP,
CreatedAt: now,
ExpiresAt: now.Add(r.ttl),
}
r.mu.Lock()
defer r.mu.Unlock()
r.pruneExpired()
r.active[nonce] = p
}
// observe marks the probe as observed if the nonce matches an active,
// non-expired entry. Returns true if the probe was found and recorded.
// No loopback filtering: every matching callback is a genuine probe
// response by construction, and tests drive it from loopback.
func (r *authProbeRegistry) observe(nonce, sourceIP string) bool {
r.mu.Lock()
defer r.mu.Unlock()
p, ok := r.active[nonce]
if !ok {
return false
}
if time.Now().After(p.ExpiresAt) {
return false
}
p.ObservedFrom = sourceIP
p.ObservedAt = time.Now()
return true
}
// get returns a snapshot of the probe for the given nonce, or false if not present.
func (r *authProbeRegistry) get(nonce string) (*authProbe, bool) {
r.mu.Lock()
defer r.mu.Unlock()
p, ok := r.active[nonce]
if !ok {
return nil, false
}
// Return a copy so the caller never races on the struct fields.
snapshot := *p
return &snapshot, true
}
// deregister removes the probe after it completes.
func (r *authProbeRegistry) deregister(nonce string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.active, nonce)
}
// generateNonce returns a 32-char hex-encoded random nonce prefixed with "atp_".
func generateNonce() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
return "atp_" + hex.EncodeToString(buf), nil
}
// clientHostFromRemoteAddr extracts the host part from a "host:port" RemoteAddr.
// If parsing fails it returns the raw value unchanged.
func clientHostFromRemoteAddr(remoteAddr string) string {
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
return host
}
return remoteAddr
}
// dnsProbeSpeakerRequest is the JSON body for POST /setup/health/dns-path-probe.
type dnsProbeSpeakerRequest struct {
DeviceID string `json:"deviceId,omitempty"`
Host string `json:"host,omitempty"`
}
// dnsProbeSpeakerResponse is the JSON response body for the dns-path probe.
type dnsProbeSpeakerResponse struct {
Success bool `json:"success"`
ObservedFrom string `json:"observedFrom,omitempty"`
LatencyMs float64 `json:"latencyMs,omitempty"`
NATOrProxy bool `json:"natOrProxy,omitempty"`
Reason string `json:"reason,omitempty"`
Remediation string `json:"remediation,omitempty"`
}
// runDNSPathProbe contains the core probe logic: resolve the target IP,
// register the nonce, send the PlayURL notification, poll for the /v1/auth
// callback up to the configured timeout, deregister the nonce, and return the
// result. It is the shared implementation used by both HandleDNSPathProbe and
// the health QuickFix registered in server.go.
func (s *Server) runDNSPathProbe(deviceID, host string) (dnsProbeSpeakerResponse, error) {
// Reuse resolveTTSHost for SSRF-safe target resolution (always a datastore IP).
targetIP, err := s.resolveTTSHost(ttsSpeakRequest{
DeviceID: deviceID,
Host: host,
})
if err != nil {
return dnsProbeSpeakerResponse{}, err
}
nonce, err := generateNonce()
if err != nil {
return dnsProbeSpeakerResponse{}, fmt.Errorf("failed to generate probe nonce: %w", err)
}
// Register BEFORE sending the notification so a fast callback matches.
s.authProbes.register(nonce, deviceID, targetIP)
defer s.authProbes.deregister(nonce)
// Build the probe URL using the service's own base URL. The speaker
// refuses the notification on 403 before fetching this URL.
probeURL := s.serverURL + "/media/tts/dns-probe"
c := client.NewClientFromHost(targetIP)
if err := c.PlayURL(probeURL, nonce, "AfterTouch DNS probe", "DNS path probe", ""); err != nil {
return dnsProbeSpeakerResponse{
Success: false,
Reason: "speaker unreachable or notification not accepted: " + err.Error(),
}, nil
}
// Determine the polling timeout: use injected value or default.
timeout := s.authProbeTimeout()
deadline := time.Now().Add(timeout)
for {
p, ok := s.authProbes.get(nonce)
if ok && !p.ObservedAt.IsZero() {
latency := p.ObservedAt.Sub(p.CreatedAt).Seconds() * 1000
return dnsProbeSpeakerResponse{
Success: true,
ObservedFrom: p.ObservedFrom,
LatencyMs: latency,
NATOrProxy: p.ObservedFrom != targetIP,
}, nil
}
if time.Now().After(deadline) {
break
}
time.Sleep(authProbePollInterval)
}
// Timeout path: notification was accepted but no /v1/auth callback arrived.
return dnsProbeSpeakerResponse{
Success: false,
Reason: "notification accepted but no /v1/auth callback received within timeout",
Remediation: "The speaker likely resolves Bose hostnames via a different DNS resolver, " +
"not AfterTouch's DNS server. " +
"To fix: point speakers at AfterTouch's DNS server via DHCP option 6, " +
"configure the upstream resolvers to forward Bose zones to AfterTouch, " +
"or re-run migration with the resolv method.",
}, nil
}
// HandleDNSPathProbe actively tests whether a specific speaker resolves Bose
// hostnames through AfterTouch's DNS by sending a /speaker notification with
// a per-probe nonce as the app_key and waiting for the speaker to call back
// at GET /v1/auth with that nonce in the Apikeyheader header.
//
// POST /setup/health/dns-path-probe
// Body: {"deviceId":"...","host":"..."}
func (s *Server) HandleDNSPathProbe(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
var req dnsProbeSpeakerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON body"}`, http.StatusBadRequest)
return
}
resp, err := s.runDNSPathProbe(req.DeviceID, req.Host)
if err != nil {
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
return
}
if encErr := json.NewEncoder(w).Encode(resp); encErr != nil {
http.Error(w, "failed to encode response", http.StatusInternalServerError)
}
}
// authProbeTimeout returns the configured probe timeout for polling. It reads
// the unexported field so tests can inject a short value without a setter.
// Falls back to the default when the field is zero.
func (s *Server) authProbeTimeout() time.Duration {
if s.authProbeTimeoutOverride > 0 {
return s.authProbeTimeoutOverride
}
return defaultAuthProbeTimeout
}
+449
View File
@@ -0,0 +1,449 @@
package handlers
import (
"encoding/json"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
)
// --- Registry unit tests ---
func TestAuthProbeRegistry_RegisterAndGet(t *testing.T) {
reg := newAuthProbeRegistry(5 * time.Second)
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
p, ok := reg.get("nonce1")
if !ok {
t.Fatal("expected probe to be found")
}
if p.Nonce != "nonce1" {
t.Errorf("nonce = %q, want nonce1", p.Nonce)
}
if p.DeviceID != "DEVICEID01" {
t.Errorf("deviceID = %q, want DEVICEID01", p.DeviceID)
}
if p.TargetIP != "192.0.2.10" {
t.Errorf("targetIP = %q, want 192.0.2.10", p.TargetIP)
}
}
func TestAuthProbeRegistry_ObserveMatch(t *testing.T) {
reg := newAuthProbeRegistry(5 * time.Second)
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
matched := reg.observe("nonce1", "192.0.2.10")
if !matched {
t.Fatal("expected observe to match")
}
p, ok := reg.get("nonce1")
if !ok {
t.Fatal("expected probe to still be present")
}
if p.ObservedFrom != "192.0.2.10" {
t.Errorf("observedFrom = %q, want 192.0.2.10", p.ObservedFrom)
}
if p.ObservedAt.IsZero() {
t.Error("observedAt is zero, expected a timestamp")
}
}
func TestAuthProbeRegistry_ObserveNoMatch(t *testing.T) {
reg := newAuthProbeRegistry(5 * time.Second)
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
matched := reg.observe("wrong-nonce", "192.0.2.10")
if matched {
t.Fatal("expected observe to not match for unknown nonce")
}
}
func TestAuthProbeRegistry_ObserveExpired(t *testing.T) {
reg := newAuthProbeRegistry(1 * time.Millisecond)
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
time.Sleep(10 * time.Millisecond)
matched := reg.observe("nonce1", "192.0.2.10")
if matched {
t.Fatal("expected observe to not match for expired nonce")
}
}
func TestAuthProbeRegistry_Deregister(t *testing.T) {
reg := newAuthProbeRegistry(5 * time.Second)
reg.register("nonce1", "DEVICEID01", "192.0.2.10")
reg.deregister("nonce1")
_, ok := reg.get("nonce1")
if ok {
t.Fatal("expected probe to be gone after deregister")
}
}
func TestAuthProbeRegistry_PrunesExpired(t *testing.T) {
reg := newAuthProbeRegistry(1 * time.Millisecond)
reg.register("old", "DEV01", "192.0.2.10")
time.Sleep(10 * time.Millisecond)
// Registering a new entry triggers pruning of expired ones.
reg.register("new", "DEV02", "192.0.2.11")
reg.mu.Lock()
defer reg.mu.Unlock()
if _, found := reg.active["old"]; found {
t.Error("expected expired entry 'old' to be pruned")
}
if _, found := reg.active["new"]; !found {
t.Error("expected new entry to be present")
}
}
// --- HandleSpeakerAuth tests ---
func probeAuthRouter(t *testing.T) (*Server, *http.ServeMux) {
t.Helper()
ds := datastore.NewDataStore(t.TempDir())
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
mux := http.NewServeMux()
mux.HandleFunc("/v1/auth", server.HandleSpeakerAuth)
return server, mux
}
func TestHandleSpeakerAuth_EmptyHeader(t *testing.T) {
_, mux := probeAuthRouter(t)
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestHandleSpeakerAuth_UnknownToken(t *testing.T) {
_, mux := probeAuthRouter(t)
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
req.Header.Set("Apikeyheader", "aftertouch")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 for unknown token", rec.Code)
}
}
func TestHandleSpeakerAuth_MatchingNonce_Returns403(t *testing.T) {
server, mux := probeAuthRouter(t)
// Register a probe nonce.
server.authProbes.register("atp_testfixednonce", "DEVICEID01", "192.0.2.10")
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
req.Header.Set("Apikeyheader", "atp_testfixednonce")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403 for matching probe nonce", rec.Code)
}
// Also confirm the probe was recorded.
p, ok := server.authProbes.get("atp_testfixednonce")
if !ok {
t.Fatal("expected probe to still be in registry")
}
if p.ObservedAt.IsZero() {
t.Error("expected ObservedAt to be set")
}
}
func TestHandleSpeakerAuth_ExpiredNonce_Returns200(t *testing.T) {
server, mux := probeAuthRouter(t)
// Register with a very short TTL.
server.authProbes = newAuthProbeRegistry(1 * time.Millisecond)
server.authProbes.register("atp_expirednonce", "DEVICEID01", "192.0.2.10")
time.Sleep(10 * time.Millisecond)
req := httptest.NewRequest(http.MethodGet, "/v1/auth", nil)
req.Header.Set("Apikeyheader", "atp_expirednonce")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 for expired nonce", rec.Code)
}
}
// --- End-to-end loop test ---
// fakeSpeaker is an httptest server that:
// 1. On POST /speaker: parses the app_key from the PlayInfo XML and calls
// GET /v1/auth on afterTouchURL with Apikeyheader set to that key.
// 2. Returns 200 for the /speaker request.
func fakeSpeaker(t *testing.T, afterTouchURL string) *httptest.Server {
t.Helper()
var callCount int32
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/speaker" {
http.NotFound(w, r)
return
}
atomic.AddInt32(&callCount, 1)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
var play models.PlayInfo
if err := xml.Unmarshal(body, &play); err != nil {
http.Error(w, "xml parse error", http.StatusInternalServerError)
return
}
// Simulate the speaker calling back /v1/auth on AfterTouch.
authReq, err := http.NewRequest(http.MethodGet, afterTouchURL+"/v1/auth", nil)
if err != nil {
http.Error(w, "build auth req error", http.StatusInternalServerError)
return
}
authReq.Header.Set("Apikeyheader", play.AppKey)
_, _ = http.DefaultClient.Do(authReq) //nolint:bodyclose
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("<status>OK</status>"))
}))
}
// probeRouter builds a minimal router with the probe and auth endpoints.
func probeRouter(t *testing.T, server *Server) *http.ServeMux {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/setup/health/dns-path-probe", server.HandleDNSPathProbe)
mux.HandleFunc("/v1/auth", server.HandleSpeakerAuth)
return mux
}
func TestHandleDNSPathProbe_EndToEnd_Success(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
if err := ds.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
// We need the AfterTouch test server URL to give to the fake speaker.
// Use a two-step approach: create the server first, then wire up the URL.
server := NewServer(ds, nil, "http://placeholder", false, false, false)
server.authProbes = newAuthProbeRegistry(5 * time.Second)
server.authProbeTimeoutOverride = 3 * time.Second
atServer := httptest.NewServer(probeRouter(t, server))
defer atServer.Close()
// Update server to know its own URL (needed for the probe URL construction).
server.serverURL = atServer.URL
// Fake speaker that calls /v1/auth on the AfterTouch test server.
speaker := fakeSpeaker(t, atServer.URL)
defer speaker.Close()
// Extract the speaker's host:port.
speakerHost := strings.TrimPrefix(speaker.URL, "http://")
// Register the speaker as a known device so resolveTTSHost accepts it.
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
IPAddress: speakerHost,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
body := `{"deviceId":"DEVICEID01"}`
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
rec := httptest.NewRecorder()
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp dnsProbeSpeakerResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body: %s", err, rec.Body.String())
}
if !resp.Success {
t.Errorf("success = false, want true; reason: %s", resp.Reason)
}
if resp.ObservedFrom == "" {
t.Error("observedFrom is empty, expected a source IP")
}
}
func TestHandleDNSPathProbe_Timeout_NoCallback(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
if err := ds.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
// A fake speaker that accepts /speaker but never calls /v1/auth back.
silentSpeaker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/speaker" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("<status>OK</status>"))
return
}
http.NotFound(w, r)
}))
defer silentSpeaker.Close()
speakerHost := strings.TrimPrefix(silentSpeaker.URL, "http://")
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
server.authProbes = newAuthProbeRegistry(5 * time.Second)
server.authProbeTimeoutOverride = 300 * time.Millisecond // fast timeout for tests
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
IPAddress: speakerHost,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
body := `{"deviceId":"DEVICEID01"}`
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
rec := httptest.NewRecorder()
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp dnsProbeSpeakerResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body: %s", err, rec.Body.String())
}
if resp.Success {
t.Error("success = true, want false (speaker never called back)")
}
if resp.Remediation == "" {
t.Error("remediation is empty, expected a hint")
}
}
func TestHandleDNSPathProbe_SpeakerUnreachable(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
if err := ds.Initialize(); err != nil {
t.Fatalf("Initialize: %v", err)
}
// Find a loopback port with nothing listening so the TCP dial fails fast
// (connection refused). We must register it as a known device first.
closedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
closedAddr := strings.TrimPrefix(closedServer.URL, "http://")
closedServer.Close() // immediately close so the port becomes unreachable
if err := ds.SaveDeviceInfo("1000001", "DEVICEID01", &models.ServiceDeviceInfo{
DeviceID: "DEVICEID01",
AccountID: "1000001",
IPAddress: closedAddr,
}); err != nil {
t.Fatalf("SaveDeviceInfo: %v", err)
}
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
server.authProbes = newAuthProbeRegistry(5 * time.Second)
server.authProbeTimeoutOverride = 300 * time.Millisecond
body := `{"deviceId":"DEVICEID01"}`
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
rec := httptest.NewRecorder()
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp dnsProbeSpeakerResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v; body: %s", err, rec.Body.String())
}
if resp.Success {
t.Error("success = true, want false (speaker unreachable)")
}
if !strings.Contains(resp.Reason, "speaker unreachable") {
t.Errorf("reason = %q, want it to mention 'speaker unreachable'", resp.Reason)
}
}
func TestHandleDNSPathProbe_InvalidBody(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(`not json`))
rec := httptest.NewRecorder()
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestHandleDNSPathProbe_UnknownDevice(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
server := NewServer(ds, nil, "http://localhost:8001", false, false, false)
body := `{"deviceId":"NOPE"}`
req := httptest.NewRequest(http.MethodPost, "/setup/health/dns-path-probe", strings.NewReader(body))
rec := httptest.NewRecorder()
http.HandlerFunc(server.HandleDNSPathProbe).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
+67
View File
@@ -105,3 +105,70 @@ func TestHandleBMXRegistry_DNSDependent(t *testing.T) {
}
})
}
func TestNormalizeServerURL(t *testing.T) {
cases := []struct {
in, want string
}{
{"http://host:8000", "http://host:8000"},
{"http://host:8000/", "http://host:8000"},
{"http://host:8000///", "http://host:8000"},
{" http://host:8000/ ", "http://host:8000"},
{"https://127.0.0.1", "https://127.0.0.1"},
{"", ""},
}
for _, c := range cases {
if got := NormalizeServerURL(c.in); got != c.want {
t.Errorf("NormalizeServerURL(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestHandleBMXRegistry_TrailingSlashServerURL is a regression test for the
// trailing-slash double-slash bug: a server_url configured with a trailing
// slash must not produce a "//bmx/..."
// base URL. The speaker concatenates "/v1/playback/station/{id}" onto the base,
// so a doubled slash yields a "//bmx/tunein/..." request the router 404s and
// TuneIn playback fails with INVALID_SOURCE.
func TestHandleBMXRegistry_TrailingSlashServerURL(t *testing.T) {
tempDir, err := os.MkdirTemp("", "bmx-registry-slash-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
// Operator typed a trailing slash (the reported trailing-slash case).
server := NewServer(ds, nil, "https://127.0.0.1/", false, false, false)
server.SetDNSSettings(false, "", "")
req := httptest.NewRequest("GET", "/bmx/v1/services", nil)
w := httptest.NewRecorder()
server.HandleBMXRegistry(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "//bmx") || strings.Contains(body, "//media") {
t.Errorf("registry response contains a doubled slash from the trailing-slash server_url:\n%s", body)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
for _, s := range resp["bmx_services"].([]interface{}) {
service := s.(map[string]interface{})
if service["id"].(map[string]interface{})["name"] == "TUNEIN" {
if baseURL := service["baseUrl"].(string); baseURL != "https://127.0.0.1/bmx/tunein" {
t.Errorf("Expected baseUrl https://127.0.0.1/bmx/tunein, got %s", baseURL)
}
return
}
}
t.Error("TuneIn service not found in registry")
}

Some files were not shown because too many files have changed in this diff Show More