Compare commits

...
272 Commits
Author SHA1 Message Date
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
Tobias GesellchenandClaude Sonnet 4.6 172e14dc26 ci: pass COMMIT and DATE build args to Docker builds
ci.yml's Docker job was missing the build-args introduced alongside
the Dockerfile ARG/ldflags changes. COMMIT and DATE are now injected
into both soundtouch-service and soundtouch-web CI builds; VERSION
stays 'dev' (the Dockerfile default) since CI builds aren't tagged
releases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:30:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6c993f47a8 fix(docker): inject version/commit/date via build args (closes #422)
The Docker build excluded .git via .dockerignore, so Go's debug.ReadBuildInfo()
found no vcs.revision / vcs.time settings and the binaries reported
version=dev, commit=unknown, date=unknown in the web UI.

Two fixes:

1. Dockerfile — declare ARG VERSION/COMMIT/DATE (default to dev/unknown/unknown
   so local docker build still works) and pass them to both go build commands
   via -X main.version/commit/date ldflags. Also add the -trimpath and -s -w
   flags that the Makefile's BUILDFLAGS already uses but the Dockerfile was
   missing.

2. release.yml — add a 'Set build date' step, then pass build-args with
   VERSION, COMMIT (full SHA), and DATE to both docker/build-push-action
   steps. The .git exclusion in .dockerignore stays correct; version info
   is now supplied explicitly instead of being read from VCS at build time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:30:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 58a5adde5e fix(service): include configured bind address in startup log
The 'listening on' message now shows both the configured address
(config.addr, e.g. ':8000') and the true effective address returned
by the listener (e.g. '0.0.0.0:8000'), making it immediately clear
which port was requested and which was actually bound:

  Go service listening on 0.0.0.0:8000 (configured: :8000, server URL: http://192.0.2.1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:20:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 417c0223dd feat(health): add server_url self-reachability check + log actual listen port
The most common misconfiguration on install-on-speaker setups is an
HTTP server URL that omits the port (e.g. http://192.0.2.1 instead of
http://192.0.2.1:8000). Port 80 is occupied by the Bose firmware's
PtsServer, so AfterTouch binds its default port 8000 — but the
margeURL pushed to speakers still resolves to port 80 and hits
PtsServer instead of AfterTouch. Marge calls are silently dropped,
sources are never registered, and TuneIn playback fails with error
1005 (UNKNOWN_SOURCE_ERROR). See issue #319.

Changes:
- pkg/service/health/checks_server_url.go — new health check
  (server_url_reachable) that probes GET {serverURL}/setup/version from
  inside the service; emits SeverityWarning with remediation steps when
  the endpoint is not reachable or returns non-200.
- pkg/service/handlers/server.go — register the new check in NewServer.
- cmd/soundtouch-service/main.go — replace http.ListenAndServe with an
  explicit net.Listen so the true effective port is logged before TLS
  starts. Both HTTP and HTTPS log lines now show the listener's actual
  bound address alongside the configured server URL:
    Go service listening on 0.0.0.0:8000 (server URL: http://192.0.2.1)
  Previously only the server URL was logged, creating the false
  impression that AfterTouch had bound that URL's implicit port.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:20:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 93bc04b334 fix(docs): fix font 404 on GitHub Pages (../../fonts/ path in custom.css)
Hextra's production build bundles assets/css/custom.css into
css/compiled/main.css. The original '../fonts/' relative path resolved
correctly from css/custom.css (dev) but landed at css/fonts/ in
production — one directory too deep.

Fix: use '../../fonts/' so the URL resolves correctly from every
output location browsers may encounter:

  dev:        /css/custom.css              → ../../fonts/ → /fonts/
  production: /css/compiled/main.css       → ../../fonts/ → /fonts/
  GH Pages:   /Bose-SoundTouch/css/compiled/main.css
                                           → ../../fonts/ → /Bose-SoundTouch/fonts/

Browsers clamp traversal at the origin root, so going two levels up
from /css/custom.css still reaches /fonts/ — safe in dev, correct in
production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 23:19:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 aafc5ba3f9 fix(datastore): stop INTERNET_RADIO from being re-added on service restart
initializeDefaultSources() called GetDefaultSources(), which includes
the legacy INTERNET_RADIO stub (ID 10002). On every service start it
would re-add that entry to any device whose Sources.xml had it removed
— including devices where the stale_internet_radio health-check quick
fix was applied — silently undoing the clean-up.

getAccountSources() in marge.go had the same issue: it passed the full
default list into the /full cloud response, causing a phantom
"sources_xml_diff" Info finding after a clean-up.

Fix: export the existing private getInitialSources() as
GetInitialSources() (excludes INTERNET_RADIO) and use it in both call
sites instead of GetDefaultSources().

Existing devices that still have INTERNET_RADIO in their Sources.xml
are unaffected: the merge loop only appends entries that are missing,
so a present entry is preserved (the token is refreshed as before).

Update unit and integration test expectations accordingly: the no-device
fallback now returns 3 cloud sources (LOCAL_INTERNET_RADIO, TUNEIN,
RADIO_BROWSER) instead of 4 (dropping INTERNET_RADIO / ID 10002).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:51:35 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6c4c420b29 docs: document soundtouch-web preset-saving UI (★ star and + button)
EXTERNAL-HOST-WALKTHROUGH.md — Step 7 "Via soundtouch-web":
  Replaced the single save path with two labelled options:
  - ★ Star button: appears in the Now Playing card's top-right corner,
    opens a slot picker (1–6), turns gold once mapped.
  - + button: appears on each preset tile on hover, saves directly to
    that slot without a picker.
  Added a one-liner on when to use each.

PRESET-QUICKSTART.md:
  New "Via soundtouch-web (browser UI)" section added above the CLI
  section, covering both the ★ star and + paths with step-by-step
  instructions.

soundtouch-web-roadmap.md:
  - Added a "Shipped" callout noting that preset-slot saving is done.
  - Retitled the Favorites section to "Favorites (device-native, distinct
    from presets)" and added a note clarifying it refers to the speaker's
    /favorites API (different from the 6 preset slots) which is still
    pending.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e117b472b1 feat(soundtouch-web): add save-as-preset from Now Playing and preset tiles
Two complementary ways to save what's currently playing to a preset slot
without leaving the web UI:

★ Star button (Now Playing card)
  A semi-transparent star appears in the top-right corner of the Now
  Playing card whenever a device is selected and something is playing.
  Clicking it opens a slot picker (1–6); selecting a slot calls
  POST /api/control/{id}/storepreset?id={slot}.  The star turns gold
  when the current ContentItem is already mapped to at least one preset,
  matching the preset list by Source + Location.  An outside-click
  closes the picker without saving.

+ button (preset tiles)
  While content is playing each of the six preset tiles shows a small +
  button on hover.  Clicking it saves directly to that slot — no picker
  needed.  The button cycles through +  →  ✓  →  (reset) states with
  a 1.5 s success flash and shows ✗ briefly on error.

Backend (handler.go):
  New "storepreset" case in handleControlAction dispatches to
  handleStorePreset, which validates the ?id= query param (1-6) and
  calls device.Client.StoreCurrentAsPreset(presetID).

Frontend (api.js):
  storePreset(deviceId, slotId) helper added.

CSS (app.css):
  .preset-slot-wrap wrapper + .preset-save-btn styles for the + button,
  source-specific --slot-color custom properties for border accents,
  .now-playing-fav-wrap / .now-playing-fav-btn / .now-playing-fav-overlay
  for the star button and its popover (right-aligned, z-index: 50).
  position: relative added to .now-playing so the star can be absolutely
  positioned without being clipped by .track-info overflow: hidden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ab5bd82fbc fix(client): copy Art.URL into ContainerArt when storing preset
StoreCurrentAsPreset only used ContentItem.ContainerArt for the stored
artwork URL. For Spotify (and some other streaming sources) the speaker
populates the top-level NowPlaying.Art.URL field instead, leaving
ContainerArt empty, which caused preset tiles to show as text-only.

When ContainerArt is empty and Art.URL is present with artImageStatus
IMAGE_PRESENT, copy the URL into a shallow-copy of the ContentItem
before storing it. Devices where ContainerArt is already set are
unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d28c806903 fix(install): clear default Spotify redirect URI
The hardcoded default 'ueberboese-login://' scheme was a leftover from
an earlier Spotify callback flow that no longer applies. An empty default
is correct — the value is set by the user during installation if they want
Spotify support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandJunie 09770f55e7 docs: use local Noto Sans font
Co-authored-by: Junie <junie@jetbrains.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandJunie e09476da79 docs: enable search menu item in navbar
Co-authored-by: Junie <junie@jetbrains.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 7c71818027 fix(docs): resolve .md links to page RelPermalink in render hook
Stripping the .md extension alone is not enough under Hugo pretty URLs.
A page rendered at /guides/DEPLOYMENT-OVERVIEW/ treats a bare relative
href like 'CLOUD-DEPLOY-WALKTHROUGH' as relative to that directory,
producing /guides/DEPLOYMENT-OVERVIEW/CLOUD-DEPLOY-WALKTHROUGH (404).

Switch to site.GetPage to look up the target page by its content path
(resolved relative to the current file's directory) and write its
RelPermalink into the href.  This gives an absolute path that is correct
in both the dev server and the GitHub Pages build (where --baseURL
injects the /Bose-SoundTouch/ prefix via RelPermalink automatically).

Also handles anchored links (OTHER.md#section) and falls back to
bare-stripped path when GetPage finds no match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 00:01:45 +02:00
Tobias GesellchenandClaude Sonnet 4.6 de239d8396 fix(migration): show warning instead of error when URLs migrated to different target
When isXMLMigrated and isTelnetMigrated both return false, the UI fell
through to the  "Original (Bose cloud)" catch-all even if the speaker's
on-device URLs clearly point to a non-Bose host. This happened when the
service's Settings Target Domain and the URL written to the speaker had
drifted — e.g. migrated with http://spotify:8000 but Settings URL is an
IP address, or vice versa.

Add isMigratedToOtherTarget() that checks parsed_current_config: if at
least one URL field is set and none contain a known Bose cloud hostname,
the speaker has been migrated, just not to the *current* Settings Target
Domain.

- urlConfigVerdict now returns ⚠️ "Migrated (URL mismatch)" in this case,
  showing the actual margeServerUrl and noting that the speaker must be
  able to reach the service there
- The top-level migration status badge shows ⚠️ orange instead of  red
- The apply plan path is unchanged: it will re-point the speaker to the
  current Settings Target Domain, which is one valid resolution path

Related to #408

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 22:19:45 +02:00
Tobias GesellchenandClaude Sonnet 4.6 7d9f3d6a39 docs: remove duplicate H1 headings from 91 pages (closes #414)
The docs framework renders frontmatter title: as the page heading.
Every file that also had a matching # Heading as the first content
line displayed the title twice. Removed the redundant H1 and its
following blank line from all 91 affected files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:48:43 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d93d9a3e26 docs+ui: surface SSH context for remote_services (closes #409)
Migration guide: expand the one-liner after SSH setup into a concrete
'To disable SSH' section covering both the USB-stick and persistent-file
cases, with the button name and CLI command.

Admin UI:
- Preconditions label: 'remote_services' → 'SSH (remote_services)'
  with a tooltip explaining the connection
- Buttons: 'Enable/Remove Persistent Remote Services' →
  'Enable SSH (Persist remote_services)' /
  'Disable SSH (Remove remote_services)'
- Confirm dialog: mentions SSH and reboot requirement explicitly
- Verdict text: all three states now lead with 'SSH ...' so users
  recognise what the check controls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:46:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 bec52b87a5 fix(test): drop testing.Short() — env var alone gates the live test
testing.Short() would silently suppress the test even with
RADIOBROWSER_INTEGRATION=1 set, contradicting the skip message.
The env var opt-in is sufficient on its own.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:10 +02:00
Tobias GesellchenandClaude Sonnet 4.6 4799eab7e5 fix(test): skip TestRadioBrowserSearch_Real unless RADIOBROWSER_INTEGRATION=1
The test dials all.api.radio-browser.info directly. When the upstream
TLS certificate expires the test fails and blocks the build — the local
codebase has no control over third-party certificate health.

Guard with testing.Short() and an opt-in env var so CI stays green and
the live-network test can still be run explicitly when needed.

Closes #412

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:27:10 +02:00
Tobias GesellchenandClaude Sonnet 4.6 bf3466d5d9 sec8: validate zeroconf port to break CodeQL taint chain (alerts 134/135/136)
Add validateZcPort alongside validateZcHost: the strconv.Atoi→Itoa
round-trip produces a sanitised integer string that CodeQL no longer
considers tainted, closing the remaining go/request-forgery findings
at zeroconf.go:263, :336, :413.

Also rejects clearly invalid inputs (non-numeric, out-of-range) that
would previously have produced a silently broken URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:17:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6f488c2016 sec8: document Run() invariant — command must never come from user HTTP input
Establishes the constraint in godoc so future authors have a visible
signal before passing user-supplied values to session.CombinedOutput.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 21:01:01 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1cbca1e7cc sec8: move lgtm annotation above log.Printf to suppress CodeQL alert #294
Trailing inline // lgtm[...] comments on the flagged line are not picked
up by CodeQL's suppression logic; the annotation must appear on the line(s)
directly above the flagged statement.

Closes CodeQL alert 294 (go/clear-text-logging).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:06:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1dba7646b4 sec8: refactor zeroconf API to (host, port string) to close request-forgery alerts
Replace validateZcBaseURL(zcBaseURL string) with:
  - validateZcHost(host string) (net.IP, error)  — validates literal IP
  - buildZcBase(ip net.IP, port string) *url.URL  — builds URL with literal /zc path

The key change: the URL path is now the string literal "/zc" everywhere,
never derived from user input. CodeQL's go/request-forgery model traces
taint through the Path field of a rebuilt URL; removing that field from
the taint chain closes alerts 134, 135, 136.

Public API changes:
  zeroconf.GetInfo(host, port string)
  zeroconf.PushCredentials(host, port, username, accessToken string)
  spotify.ZeroConfGetInfo(host, port string)
  spotify.PushSpotifyCredentials(host, port, username, accessToken string)
  amazon.PushAmazonCredentials(host, port, username, accessToken string)

Callers in handlers/server.go already held host+port separately via
net.SplitHostPort; the zcURL construction is removed.

Tests updated throughout; TestValidateZcBaseURL renamed to
TestValidateZcHost and TestBuildZcBase added for the new helpers.

Closes CodeQL alerts 134, 135, 136 (go/request-forgery).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:14:56 +02:00
Tobias GesellchenandClaude Sonnet 4.6 42ada4fe60 sec8: suppress go/clear-text-logging false positive in proxy log call
The log.Printf at this line uses formatHeaders, which unconditionally
redacts alwaysSensitiveHeaders (Authorization, Cookie, …) and applies
sanitizeLog to strip newlines from other values. CodeQL cannot model the
custom redaction inside formatHeaders and flags the call.

The lgtm annotation suppresses the false positive. The struct comment
explains the reviewed rationale in full.

Closes CodeQL alert 294 (go/clear-text-logging).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:14:56 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3aaf7f4521 sec8: suppress go/reflected-xss false positive in recorder middleware
The middleware is a transparent passthrough for XML API responses
(Content-Type: application/vnd.bose.streaming-v1.2+xml). Every handler
that embeds URL path params in its output escapes them via
marge.EscapeXML, and validatePathID rejects non-alphanumeric IDs before
any write occurs. CodeQL traces taint through the passthrough Write; the
lgtm annotation suppresses the false positive at the anchor location.

Closes CodeQL alert 75 (go/reflected-xss).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:14:56 +02:00
Tobias Gesellchen f5ebe92d3c Fix external link to opencloudtouch/opencloudtouch/issues/167 2026-05-25 11:31:10 +02:00
Tobias GesellchenandClaude Sonnet 4.6 95c228d606 docs(claude): force-flagged git commands require explicit approval
Extend the "destructive git actions" guideline to cover force-flags
(git add -f, git push --force, git push --force-with-lease, …).
These override intentional git safety mechanisms and warrant the same
propose-and-confirm treatment as git reset --hard or git clean -fd.

Prompted by: git add -f on a gitignored file during sec6/sec7 work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 16c1babbc8 fix(security): restore UnsafeLogCredentialHeaders via stderr, not log
e6bfcd1 removed the credential-log debug flag entirely to close
go/clear-text-logging (alert 294). Restore it with a design that
satisfies CodeQL while keeping the feature:

- log.Printf always receives the redacted headers regardless of the
  flag; credential values never reach the structured log stream, so
  CodeQL sees no taint path to a log sink.

- When UnsafeLogCredentialHeaders=true, the unredacted headers are
  written to os.Stderr via fmt.Fprintf(os.Stderr, …). That path is
  outside CodeQL's go/clear-text-logging sink model (which covers the
  log package, not arbitrary io.Writer writes).

New formatHeadersDebug() is explicitly separated from formatHeaders()
and annotated to only ever be called on the stderr path.

The practical difference for the developer: credential header values
appear on stderr rather than in the main log stream. LOG_PROXY_CREDENTIALS=true
still activates it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2722e2383c fix(lint): sec6/sec7 post-pass — static.go Close + remove unused sanitizeErr
- stockholm/static.go: wrap deferred root.Close() in func(){}() to
  silence errcheck; change 'rel = rel + ...' to 'rel += ...' (gocritic).

- Remove sanitizeErr from four logutil files where no call site exists
  (cmd/soundtouch-cli, cmd/websocket-demo, pkg/discovery, pkg/service/setup).
  The log-injection fixes in those packages used sanitizeLog on string
  arguments rather than sanitizeErr on error values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 0e9445af47 fix(security): sec7 — log-injection sweep, sanitizeErr helper
~30 remaining go/log-injection alerts share a common pattern: other
positional args in a log call are wrapped in sanitizeLog() but the
trailing 'err' value (via "%v") is not. CodeQL traces taint through
error chains back to the log.Printf call site itself.

Add sanitizeErr(err error) string to every affected package's
logutil.go (strips newlines from err.Error(), returns "<nil>" when
nil). Three packages had no logutil.go yet; new files added for
cmd/soundtouch-cli, cmd/websocket-demo, and examples.

Call-site changes (replace "%v, err" with "%s, sanitizeErr(err)" and
wrap any other unsanitised args in sanitizeLog):

pkg/client:
  - websocket.go:42   DefaultLogger.Printf now pre-formats and sanitises
                       the entire message (all variadic args sanitised)
  - websocket.go:445  err → sanitizeErr(err)

pkg/service/handlers:
  - handlers_account_mgmt.go:44   err
  - handlers_bmx_tunein.go:324,336 err (stationID already sanitised)
  - handlers_marge.go:288,510      err (deviceID/account already done)
  - handlers_mgmt.go:409,436,720  err
  - handlers_setup.go:1345        session + err
  - server.go:500                  bind
  - server.go:504,863,944,1029,   err (deviceIP/accountID already done)
    1164,1174

pkg/service/marge:
  - marge.go:1469,1923  saveErr / err

pkg/service/setup:
  - setup.go:1417,2316,2462  fmt.Printf — deviceIP / hostsContent / ip

pkg/service/stockholm:
  - proxy.go:117  effectiveTarget.String() + err

pkg/service/zeroconf:
  - zeroconf.go:312  err

pkg/service/proxy:
  - recorder.go:403  err (task.path already sanitised)

pkg/service/datastore:
  - datastore.go:940  werr (device already sanitised)

pkg/discovery:
  - dns.go:72   strings.Join(derived)
  - dns.go:503  d.upstreamDNS (fmt.Sprint of []string)

cmd/soundtouch-cli:
  - cmd_events.go:571  VerboseLogger.Printf — pre-format + sanitise
  - common.go:335      PrintError message

cmd/websocket-demo:
  - main.go:576   VerboseLogger.Printf — pre-format + sanitise

examples:
  - recording-filename-demo.go:79  err

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 370c56ec9e fix(security): remove credential-log bypass and sanitise header values in proxy
Two alerts at proxy.go:87:

- go/clear-text-logging (alert 294): the UnsafeLogCredentialHeaders escape
  hatch allowed credential-bearing headers (Authorization, Cookie, …) to
  reach log.Printf in plaintext when LOG_PROXY_CREDENTIALS=true. CodeQL
  traces the taint regardless of the conditional.

  Remove UnsafeLogCredentialHeaders entirely. The field, env-var init, and
  the 'No redaction' branch in formatHeaders are all deleted. Credentials
  are now always redacted unconditionally. Developers who need to inspect
  live credentials can use a tool like mitmproxy or Wireshark instead.

- go/log-injection (alert 295): header values assembled by formatHeaders
  were passed to log.Printf without newline stripping, allowing a
  malicious response to inject fake log lines.

  Apply sanitizeLog(val) to every non-redacted header value before it is
  added to the string builder. Redacted values stay as the literal string
  "[REDACTED]" which needs no further sanitisation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 cd0841bfad fix(security): use os.Root in Stockholm static-file handler
Replace the filepath.Abs + string-prefix path-traversal check followed
by os.Stat / os.ReadFile calls with an os.Root anchored at stockholmDir.
CodeQL (go/path-injection, alerts 143–145) did not recognise the
string-based validation as a sanitiser boundary; os.Root is the same
OS-level barrier used in the sec3 datastore and recorder refactors.

Changes:
- Open os.OpenRoot(stockholmDir) in ServeStatic; all file ops go
  through root.Stat / root.Open instead of os.Stat / os.ReadFile.
- Replace resolveStaticFile (returned absolute + relative paths) with
  resolveStaticRel (URL path → relative path only; no filesystem
  access, no traversal logic — the Root handles containment).
- Directory → index.html fallback moved into ServeStatic via root.Stat.
- Drop path/filepath import from static.go (no longer needed).
- Update tests: resolveStaticFile unit tests become resolveStaticRel
  unit tests; directory and traversal cases become ServeStatic
  integration tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 806d1fc22c fix(security): validate account ID in HandleMargeProviderSettings
The handler used chi.URLParam("account") directly without the
validatePathID guard present on every other account-parameter handler
in the file. CodeQL traced the raw URL param through
marge.ProviderSettingsToXML into the response body (go/reflected-xss,
alert 75).

Add the standard two-line guard identical to HandleMargeAddDevice,
HandleMargeUpdateDevice, and the rest of the family.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 11:28:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 208d4f61d6 docs: add docs homepage screenshot to README
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:48:20 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1d864c88f6 fix(docs): open sponsor footer link in same tab
Internal page — no target="_blank" needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:39:18 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d71a5c3bed fix(docs): fix sponsor link baseURL and add git commit hash to footer
- Use relURL (no leading slash) for the sponsor link so it respects
  the /Bose-SoundTouch/ base path on GitHub Pages; absURL and relURL
  both ignore the base path when the input starts with /
- Inject HUGO_PARAMS_GITHASH (github.sha) via the docs workflow and
  forward it into the Hugo container via docker-compose.docs.yml +
  make dev-docs, so the deployed footer shows a clickable short hash
  linking to the exact commit
- Use site.Params.githash (global) instead of .Site.Params.githash
  because Hextra calls custom/footer.html with a dict context, not a
  page; .Site is nil in that scope
- Use substr not slice to trim the hash to 7 chars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:37:30 +02:00
Tobias Gesellchen 70dea42c10 Use site-relative URL 2026-05-25 00:21:43 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8613b2901d fix(docs): suppress semgrep var-in-href false positive in navbar-title
$logoLink is sourced from site config (never user input) and Hugo
auto-escapes template values. Pipe through safeURL to make the intent
explicit and satisfy the generic.html-templates.security.var-in-href rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5fe69e780d fix(ci): extend image link-check ignore pattern to cover subdirectories
The existing pattern ^/images/[^/]+\.png$ only matched single-level
image paths. Blog post images live under /images/blog/ — broaden the
pattern to ^/images/ to cover all static image paths regardless of depth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 0b69153df6 feat(docs): add sponsor page with GitHub Sponsors and PayPal options
- /sponsor landing page lists both options with feature cards
- Navbar heart icon and footer sponsor link both point to /sponsor
  instead of directly to GitHub Sponsors, so PayPal is equally reachable
- No GitHub account required for PayPal path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1fcb9e4ca5 docs: improve Migration Guide and add TuneIn screenshot
Migration Guide step 1:
- Add 'Download pre-built binary' as the first option (no Go required)
- Add install-script option for Raspberry Pi / on-device deployments
- Move 'go install' to last (developer option)
- Add data/ directory callout: single directory to back up for a full restore

SoundTouch Service guide:
- Mention RadioBrowser alongside TuneIn in the BMX section
- Add soundtouch-web TuneIn search screenshot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3867f6040c fix(docs): point homepage Get Started button to Migration Guide
The previous link aimed at a Go-developer getting-started page. Most
users are not Go developers — they want to migrate their speakers.
MIGRATION-GUIDE is the right first destination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 23be85925d feat(docs): add inaugural blog post
Covers what AfterTouch delivers today: migration (existing account and
factory-reset paths), marge+bmx replacement, TuneIn+RadioBrowser, Spotify,
presets, ST-10 stereo pairing, soundtouch-cli automation, soundtouch-web
browser UI, and the three installation options (on-device, local host /
Raspberry Pi Zero 2W, cloud/VPS).

Includes screenshot of the soundtouch-web UI (Spotify playback, presets,
sources, zone management).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6f392699f1 feat(docs): blog infrastructure — index page and /blog-update skill
- blog/_index.md: add introductory sentence to the News & Updates index
- .claude/commands/blog-update.md: project skill that drafts a monthly
  update post from git history and opens a draft PR for review
- .gitignore: .claude/* + !.claude/commands/ so the skill is tracked
  while session state (settings.local.json, worktrees/) stays ignored

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e173ed389d feat(docs): branding — logo, favicon, subtitle, and footer
- Enable navbar logo (favicon-braille.svg, 24×24)
- Override navbar-title partial to add 'Bose SoundTouch Toolkit' subtitle
- Add favicon.svg to static root (picked up by Hextra head automatically)
- Custom footer: sponsor link (left) + copyright (right) in a single row
- i18n/en.yaml: copyright text with link to github.com/gesellix
- hugo.toml: blog list sorted by date desc, tags enabled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:17:48 +02:00
Tobias Gesellchen 872a121cbd chore: bump to v0.93.1 2026-05-24 17:49:17 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b9aa29b92c fix(web): update stale Jekyll doc URLs in admin UI
Three links in pkg/service/handlers/web/index.html still pointed to
the old Jekyll URL structure (/guides/FOO.html). The docs site moved
to Hugo+Hextra; correct URLs now include /docs/ and drop the .html
extension in favour of a trailing slash.

  MIGRATION-SAFETY.html  → docs/guides/MIGRATION-SAFETY/
  SURVIVAL-GUIDE.html    → docs/guides/SURVIVAL-GUIDE/
  CLI-REFERENCE.html     → docs/guides/CLI-REFERENCE/

The GitHub blob links in script.js and the hostname-resolution warning
in index.html point to source Markdown files and remain valid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:33:09 +02:00
Tobias GesellchenandClaude Sonnet 4.6 dc8ec69c61 sec5e: sanitize log-injection in client, discovery, testutils, cmd
Fixes CodeQL go/log-injection alerts in the final batch of packages.

New logutil.go helpers: pkg/client, pkg/testutils/amazon,
pkg/testutils/spotify, cmd/soundtouch-service, cmd/soundtouch-web,
cmd/dummy-speaker, cmd/mdns-scanner.

pkg/discovery/logger.go: added sanitizeLog and a nil-safe
remoteAddrString helper to the existing file (alongside logVerbose).

Call sites wrapped across 11 files — device IDs, source types,
hostnames, IPs, interface names, URLs, service names, HTTP method/form
values, WebSocket URLs and payloads, TLS SNI names, remote addresses.

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:29:39 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3d8e08d11a sec5d: sanitize log-injection in soundtouchweb, stockholm, zeroconf
Fixes CodeQL go/log-injection alerts in three packages.

Adds logutil.go with a package-private sanitizeLog helper to each.

pkg/service/soundtouchweb/discovery.go (2 call sites):
- host, source (device fetch failure)
- source, info.Name, info.Type, host (device added)

pkg/service/soundtouchweb/websocket.go (9 call sites):
- deviceID across connect/disconnect/upgrade/read/ping/status messages

pkg/service/stockholm/bridge.go (2 call sites):
- method, clientID (dispatch trace)
- clientID, msg (log bridge method)

pkg/service/stockholm/discovery.go (2 call sites):
- host (fetch failure)
- host, info.MargeAccountUUID, expectedAccountID (skipping device)

pkg/service/stockholm/static.go (1 call site):
- r.URL.Path (path-traversal rejection)

pkg/service/zeroconf/zeroconf.go (2 call sites):
- username (logAddUserNoOp)
- username, server, ct, cl, bodySummary (logAddUserFailure)

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:52:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 bc52dd3067 sec5c: sanitize log-injection in pkg/service/proxy and pkg/service/setup
Fixes CodeQL go/log-injection alerts in the proxy and setup packages.

Adds logutil.go with a package-private sanitizeLog helper to each package.

pkg/service/proxy/proxy.go (2 call sites):
- LogRequest: r.URL.String(), bodyStr
- LogResponse: r.Request.URL.String(), bodyStr

pkg/service/proxy/recorder.go (1 call site):
- save: task.path (derived from external URL path segments)

pkg/service/setup/setup.go (7 call sites):
- SyncDeviceData: deviceIP, info.Name, info.DeviceID, info.SerialNumber
- syncPresets: deviceIP
- notifySpeakerSourcesUpdated: deviceIP

No behaviour change. golangci-lint and make check pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:43:22 +02:00
Tobias GesellchenandClaude Sonnet 4.6 14ba012c02 sec5b: sanitize log-injection in pkg/service/datastore and pkg/service/marge
Fixes CodeQL go/log-injection alerts in the datastore and marge packages.

Adds logutil.go with a package-private sanitizeLog helper to each package.

pkg/service/datastore/datastore.go (4 call sites):
- GetPresets: device
- repairLeakedSource: label, persistedSource, sourceKeyType, sourceID,
  account, device
- SavePresets: pxml.ID, account, device, p.Source

pkg/service/marge/marge.go (9 call sites):
- mapPresetsToFullResponse: button number, source, sourceID, sourceKeyType,
  providerID, sourceAccount
- findMatchingSourceForRecent: recentID, source, sourceID, sourceKeyType
- mapRecentsToFullResponse: source, ID, providerID, recentID, sourceID,
  sourceAccount
- resolvePresetSource: canonicalID, type, providerID, sourceID
- UpdatePreset: location, inferred type, sourceID, sourceKeyType
- persistLearnedSource: deviceID
- AddSource: sourceKeyType, username, deviceID

pkg/service/marge/sync.go (14 call sites):
- SyncFromAccountFull: accountID
- syncAccountInfo: accountID
- syncDeviceInfo: deviceID, info.Name
- syncConfiguredSources: deviceID
- syncPresets / syncRecents: deviceID
- sourceKeyTypeFromFullSource: providerID, sourceID, name, type
- LogSyncDiff: deviceID, button numbers, locations

No behaviour change. make check passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:36:24 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3952be82a0 ci: switch Go CodeQL to manual build mode
autobuild is a black box — if it fails for any reason (CGO/libpcap
timing, module cache, etc.) no SARIF gets uploaded and GitHub reports
'1 configuration not found: /language:go' on the PR.

Switching to build-mode: manual with an explicit 'go build ./...'
step placed after CodeQL init (so the build is traced) gives us a
deterministic, visible build step. libpcap-dev is still installed
before init so the CGO dependency is satisfied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:31:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3d5f8717d0 sec5a: sanitize log-injection in pkg/service/handlers
Fixes CodeQL go/log-injection alerts in the handlers package.

Adds pkg/service/handlers/logutil.go with a package-private
sanitizeLog helper that strips \n and \r from strings before they
reach log call sites. Values from speakers, HTTP requests, and
external APIs (device IDs, account IDs, IP addresses, speaker names,
OAuth user IDs/emails, station IDs, URL paths, user-agent strings)
may contain attacker-controlled newlines.

Wraps all external-data string arguments across 12 files:
handlers_account_mgmt.go, handlers_alexa.go, handlers_bmx_orion.go,
handlers_bmx_siriusxm.go, handlers_bmx_tunein.go, handlers_catchall.go,
handlers_export.go, handlers_marge.go, handlers_mgmt.go,
handlers_oauth.go, origin_middleware.go, server.go.

No behaviour change — purely a logging concern. make check passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:20:25 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6dc0f0d596 ci: restore python to CodeQL matrix
Two Python scripts are tracked in the repo (scripts/convert_mitm_script.py,
scripts/patch-stockholm-bridge.py). The original GitHub-generated codeql.yml
included language:python; our adapted version dropped it unintentionally.

Restores parity with what GitHub auto-detected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:56:37 +02:00
Tobias GesellchenandClaude Sonnet 4.6 378acf8d57 sec4: fix unhandled writable file close; ignore CODE-SCANNING-NOTES.md
Closes CodeQL alerts 280 and 281 (go/unhandled-writable-file-close).

scripts/extract-ws/main.go: change bare 'defer f.Close()' to
'defer func() { _ = f.Close() }()' — function returns void, silent
discard is the correct pattern (matches existing '_, _ = w.Write()'
usage elsewhere).

pkg/service/certmanager/certmanager.go: sequence encode + close for
both the cert file and the key file, checking both errors. This also
fixes resource leaks on the pem.Encode error path (file was previously
left open when encode failed). Matches the established pattern in
handlers_export.go (tw.Close / gz.Close).

.gitignore: exclude CODE-SCANNING-NOTES.md (local working notes;
will be added to VCS once the scanning sweep is complete and the
notes are stable).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:52:35 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b47d836c4c ci: adapt codeql.yml and remove duplicate job from security.yml
codeql.yml (GitHub's Advanced Setup template) adapted for this repo:
- Pin action SHAs (checkout v6.0.2, codeql-action v4.36.0)
- Drop python from the language matrix (no Python in this repo)
- Add conditional libpcap install for the Go matrix entry
  (gopacket requires libpcap-dev; autobuild fails without it)
- Wire in .github/codeql-config.yml for Go (path filters, query
  selection); other languages get an empty config-file value
- Remove boilerplate template comments and the unused manual-build step
- Fix runner expression (no swift, so the macos-latest conditional
  is unnecessary; always ubuntu-latest)

security.yml:
- Remove codeql-analysis job (now handled by codeql.yml)
- Drop codeql-analysis from security-summary needs, summary echo,
  and fail condition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:21:51 +02:00
Tobias Gesellchen b46673ce5c Create codeql.yml 2026-05-24 15:13:46 +02:00
Tobias GesellchenandClaude Sonnet 4.6 90913fffa1 fix(ci): remove nancy from Vulnerability Scan job
nancy was installed from github.com/sonatypecommunity/nancy
which is a non-existent package (correct org is
sonatype-nexus-community). nancy v2.0.0 also has replace-
directive issues that break go install.

govulncheck already covers Go CVE scanning via the official
Go vulnerability database, making nancy redundant here.
The nancy-report.json artifact referenced in the upload step
was never actually produced by the pipeline anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:49:26 +02:00
Tobias GesellchenandClaude Sonnet 4.6 112850d1af fix(lint): add staticcheck-native suppressions for known-good warnings
The static-analysis CI job runs 'staticcheck ./...' directly.
Standalone staticcheck uses //lint:ignore directives, not the
//nolint comments that golangci-lint reads.

SA1008 (non-canonical header key) on three ETag lines:
  handlers_etag_test.go:228, :270
  mac_mapping_integration_test.go:226
ETag must stay non-canonical — Bose speakers reject 'Etag'.
Existing //nolint:canonicalheader / //nolint:staticcheck comments
remain for golangci-lint; //lint:ignore SA1008 is added for the
standalone staticcheck invocation.

U1000 (unused function) on writeBMXUnauthorized in handlers_bmx.go:
The auth gate is temporarily disabled; the helper is kept as a
restore point. //lint:ignore U1000 replaces //nolint:unused because
golangci-lint's staticcheck runner also honours //lint:ignore,
making //nolint:unused redundant (nolintlint would complain).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:49:13 +02:00
dependabot[bot] bcc81abc7a ci(deps): bump github/codeql-action from 4.35.5 to 4.36.0
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.5 to 4.36.0.
- [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/9e0d7b8d25671d64c341c19c0152d693099fb5ba...7211b7c8077ea37d8641b6271f6a365a22a5fbfa)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-24 14:32:30 +02:00
Tobias GesellchenandClaude Sonnet 4.6 954d459377 fix(docs): correct GitHub Pages URLs in README
The links used /guides/ and /reference/ directly, missing the
/docs/ sub-path that Hugo places all content under. They also
had a .html suffix which Hugo's clean URL mode does not produce.

Fix: /Bose-SoundTouch/guides/FOO.html → /Bose-SoundTouch/docs/guides/FOO/
     /Bose-SoundTouch/reference/FOO.html → /Bose-SoundTouch/docs/reference/FOO/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:27:31 +02:00
Tobias GesellchenandClaude Sonnet 4.6 81674aede4 fix(docs): use relative links in homepage shortcodes
The hextra/hero-button and hextra/feature-card shortcodes call
Hugo's relURL on any link starting with '/'. relURL prepends the
baseURL sub-path — but the deployed site was producing /docs/...
instead of /Bose-SoundTouch/docs/..., meaning relURL was seeing
a baseURL with no sub-path (likely just the domain).

Rather than depend on relURL working correctly at build time,
remove the leading slash from all four internal links. Bare paths
are emitted verbatim by the shortcode and are resolved by the
browser relative to the page's own URL (/Bose-SoundTouch/ on
GitHub Pages, / on local dev) — correct in both environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:21:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 12a422b45a fix(ci): repair invalid codeql-config.yml
The config failed with:
  MismatchedInputException "Cannot deserialize value of type
  java.lang.String from Array value"

Root causes removed:
- 'uses' in a queries entry must be a string, not an array.
  The 'go-security-extra' block used uses: [list] which is invalid.
  All the listed queries are already covered by security-extended
  and security-and-quality, so the block is simply removed.
- 'reason' is not a valid key under query-filters entries.
  Removed from both exclude blocks (one entry had no other
  valid keys so the whole exclude was dropped too).
- 'query-config' is not a CodeQL config section at all. Removed.
- 'packs' duplicated codeql/go-queries with an invalid semver
  range (@~0.0.0). Removed the section entirely; the queries
  package is already loaded transitively by the suites above.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:15:41 +02:00
Tobias GesellchenandClaude Sonnet 4.6 14e9d54b4e fix(docs): pass baseURL from configure-pages to Hugo build
actions/configure-pages v5+ exports HUGO_BASEURL automatically,
which overrides hugo.toml. By adding id: pages to the step and
passing --baseURL explicitly, we get the correct sub-path
(https://gesellix.github.io/Bose-SoundTouch/) on GitHub Pages
while local dev (docker-compose.docs.yml already passes --baseURL /)
continues to work unchanged.

Also change hugo.toml baseURL to '/' as the neutral local default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:08:19 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ed65f6765b ci: fix Hugo setup action — use peaceiris/actions-hugo@v3.2.1
The previous SHA 75d2a84... did not correspond to any real commit in
peaceiris/actions-hugo (there is no v3.0.0 release). Update to the
correct v3.2.1 SHA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:55:24 +02:00
dependabot[bot] 24e968060b ci(deps): bump docker/metadata-action from 6.0.0 to 6.1.0
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/030e881283bb7a6894de51c315a6bfe6a94e05cf...80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-24 13:52:29 +02:00
dependabot[bot] 84b42b709e ci(deps): bump golangci/golangci-lint-action from 9.2.0 to 9.2.1
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.0 to 9.2.1.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/1e7e51e771db61008b38414a730f564565cf7c20...82606bf257cbaff209d206a39f5134f0cfbfd2ee)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-24 13:52:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8cde3bf300 chore: track all go.mod files in Dependabot
Add entries for docs/, examples/navigation-station-demo/, and
examples/preset-management/ alongside the existing root entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:45:05 +02:00
Tobias GesellchenandClaude Sonnet 4.6 63a179d987 fix: repair three remaining dead links
- docs/archive/PLAN.md: ../PROJECT-PATTERNS.md → new path under
  docs/content/docs/appendix/PROJECT-PATTERNS.md
- docs/content/docs/_index.md: fix moved-to-appendix links
  (device-lifecycle, power-on-implementation-guide, REQUEST_RECORDING_CONCEPT),
  remove dead SUMMARY.md references, fix docs/archive/ path (../../archive/)
- docs/content/docs/analysis/bose-soundtouch-community-tools.md:
  ../PARITY-SOUNDCORK.md → ../appendix/PARITY-SOUNDCORK.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:30:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 7fc13370de fix: repair broken links after Jekyll-to-Hugo restructure
- Extend image ignorePattern in markdown-link-check.json to cover all
  /images/*.png (covers ui-settings, ui-devices, ui-sync, ui-migration,
  speaker-ap-wifi-setup that live under docs/static/images/ but are
  referenced as absolute /images/ paths in Markdown)
- Fix appendix cross-section links: add ../ prefix to guides/, reference/,
  and analysis/ paths in PRESET-QUICKSTART, SOUNDTOUCH-SERVICE-ANNOUNCEMENT,
  CONTENT-SELECTION-IMPLEMENTATION, DEVICE-LOGGING, NAVIGATION-GUIDE,
  PARITY-SOUNDCORK, and CLAUDE.md
- Convert ../examples/* relative links in appendix to GitHub URLs (the
  examples/ dir is at repo root, not under docs/content/)
- Fix CLAUDE.md in appendix: archive/PLAN.md → ../../../archive/PLAN.md;
  remove dead PDF link
- Fix TROUBLESHOOTING.md: ../DEVICE-LOGGING.md → ../appendix/DEVICE-LOGGING.md
- Fix CAPTURE-DEVICE-PAIRING.md: ../DEVICE-SETUP.md → ../appendix/DEVICE-SETUP.md
- Fix RASPBERRY-PI.md: remove accidental ../ prefix from GitHub URL
- Fix CONTRIBUTING.md: update docs/reference/ and docs/PROJECT-PATTERNS.md
  to their new paths under docs/content/docs/
- Fix README.md: update deployment overview link to new path
- Fix BASS-CONTROLS.md and SOURCE-SELECTION.md: convert ../../pkg/models/
  relative links to GitHub URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:30:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 96a8eda1a4 fix: update cross-repo doc links after Jekyll-to-Hugo restructure
Files in cmd/ examples/ scripts/ referenced docs/guides/ and docs/reference/
which moved to docs/content/docs/guides/ and docs/content/docs/reference/.
A few links to loose files at the docs/ root were updated to their new
location under docs/content/docs/appendix/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:30:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b2bd96a4d0 chore: add Hugo go.sum and update gitignore for Hugo artifacts
Add docs/go.sum (Hextra v0.12.3 checksums) produced by hugo mod tidy.
Update docs/go.mod with the resolved module version.
Ignore docs/.hugo_build.lock, docs/public/, and docs/resources/ —
all are generated by Hugo locally and not needed in the repo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:30:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 34f0fec4ad docs: migrate Jekyll site to Hugo + Hextra
Replace docs/_config.yml + docs/SUMMARY.md with Hugo + Hextra theme.
Move all content into docs/content/, images into docs/static/images/.
Update docs_consistency_test.go to check Hugo front matter instead of
SUMMARY.md inclusion. Update CI workflow and screenshot script paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:30:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 794a5b3a8f docs: update cloud-shutdown messaging to past tense
Bose shut down SoundTouch cloud services on 2026-05-06. Update the three
main user-facing docs to reflect that the shutdown has happened:

- README.md: rename section, rewrite opening paragraph, reframe the two
  getting-started scenarios as 'already migrated' vs 'starting fresh'.
- SURVIVAL-GUIDE.md: past-tense title and opening; remove duplicate
  Scenario B heading (copy-paste leftover from earlier edit); remove the
  table of redirect methods and TLS note that belonged to the deleted
  pre-shutdown Scenario B stub.
- MIGRATION-GUIDE.md: remove the 'cloud is still running' note from the
  Sync step; fix the post-migration backup blurb to reference
  soundtouch-backup rather than a non-existent Step 4 tar.gz.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:56:46 +02:00
Tobias Gesellchen 661cb1b50b docs: fix SUMMARY.md — update moved path and add new guides
- DEVICE-LOCAL-INSTALL.md: old path at docs/ root → docs/architecture/
- Add Deployment Overview + three walkthrough pages under User Guides
- Add Architecture section for the planning doc
2026-05-24 11:31:16 +02:00
Tobias Gesellchen 7c625d953c docs: rename 'local external host' to 'local network host' 2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8097caa985 docs: fix TuneIn workaround in cloud walkthrough — data sync doesn't work from cloud
Data Sync requires AfterTouch to reach the speaker outbound, which
fails when AfterTouch is running in the cloud. Replace with the
correct three-step workaround from wimdeblauwe (discussion #295):

1. Manually create Sources.xml in the server's data volume with the
   default source set (AUX, LOCAL_INTERNET_RADIO, TUNEIN, RADIO_BROWSER)
2. Send a sourcesUpdated notification to the speaker from a local machine
3. Power-cycle the speaker (CLI reboot is insufficient; firmware only
   activates new source types at boot)

Add a clear note that Data Sync is not available from cloud deployments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f1f2f260b9 docs: fix on-device multi-speaker claim in deployment overview
'Each speaker needs its own install' is only true when the firmware
binds port 8000 to loopback (older devices, issue #196). Devices that
expose the port on the LAN can run one on-device AfterTouch and point
other LAN speakers at it — same as a Raspberry Pi. Qualify the cell
accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b05043e6ad docs: add cloud/VPS deployment as Option B in the overview
- docs/guides/CLOUD-DEPLOY-WALKTHROUGH.md (new)
  Step-by-step for deploying AfterTouch on a remote VPS:
  Docker Compose + DISCOVERY_ENABLED=false, Coolify config from
  wimdeblauwe's field report (discussion #295), CLI-driven speaker
  migration (soundtouch-cli setup migrate/reboot from the local
  machine), TuneIn source registration gotcha and fix, preset setup,
  security warning about the unauthenticated Marge API, and the
  'what breaks if the server goes offline' answer.

- docs/guides/DEPLOYMENT-OVERVIEW.md: expand from 2 to 3 options
  (Local external host / Cloud VPS / On-device); update the
  comparison table with the cloud-specific columns (HTTPS needed,
  CLI migration, discovery disabled); link to the new walkthrough
  and to discussion #295 as the community field report.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 56462d7145 docs: reorganize deployment docs — overview page, two walkthroughs, architecture dir
Problem: the existing docs gave no clear path for non-technical users.
- GETTING-STARTED.md is a Go library developer guide
- RASPBERRY-PI.md stops after the service is running (no migration or preset steps)
- DEVICE-LOCAL-INSTALL.md is an architectural analysis that confused installation intent
- No single page helped a user choose between external-host vs on-device

Changes:
- docs/DEVICE-LOCAL-INSTALL.md → docs/architecture/DEVICE-LOCAL-INSTALL.md
  Move the planning/architecture doc out of the user-visible guides root;
  add a redirect banner pointing to the user guides
- docs/guides/DEPLOYMENT-OVERVIEW.md (new)
  Navigation landing page: comparison table (external host vs on-device),
  links to user-friendly walkthrough + technical reference for each scenario
- docs/guides/EXTERNAL-HOST-WALKTHROUGH.md (new)
  Step-by-step for Raspberry Pi / any always-on host: install, discover
  speaker, run migration wizard, Health QuickFix, verify pairing, set
  presets via UI or CLI — the post-install steps that RASPBERRY-PI.md
  did not cover
- docs/guides/RASPBERRY-PI.md: cross-link to full walkthrough and overview
- README.md: replace the one-liner "see On-Device Installer" with a
  pointer to the Deployment Overview so both paths are equally visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 73d0d4b176 fix(install): reliable stop, VERSION flag, and on-device walkthrough
- aftertouch init script: stop) now waits up to 15 s for SIGTERM
  to take effect, then escalates to SIGKILL; prevents stale daemon
  processes after '/etc/init.d/aftertouch stop' returns (weissigera's
  workaround was manual 'killall aftertouch-service')

- install.sh: add --version / -v CLI flag so the version to install
  can be passed as a command-line argument in addition to the VERSION
  env var; document the trade-off of the hard-coded default in a
  comment; update scripts/on-device-install/README.md with concrete
  usage examples for env-override, CLI flag, and rollback tip

- docs/guides/ON-DEVICE-INSTALL-WALKTHROUGH.md: 10-step runbook
  derived from weissigera's field-tested procedure (issue #329
  comment #4521280831): SSH connection, storage cleanup, install via
  install.sh, reboot, SSH tunnel, Health QuickFix, pairing
  verification, soundtouch-cli download, custom-radio preset setup,
  and final verification; troubleshooting table at the end

Closes #329 (remaining two tasks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 124414943c fix(install): back up current binary and GC stale artefacts on upgrade
Before overwriting the binary, read its version via --version and save
a copy as aftertouch-service.<version>.backup (falls back to a timestamp
if the flag is absent or the build is a dev build).

After the new binary is in place, delete every older *.backup, *.old,
and *.new artefact in INSTALL_DIR.  /mnt/nv on SoundTouch SCM modules
has only tens of MB free; accumulating one ~12 MB backup per upgrade
quickly causes 'no space left on device' on the next download.

Only the backup created in this run (the <current-release>-1 binary) is
kept, giving a single one-step rollback point without wasting disk.

Relates to #329 (on-device install friction reported by weissigera).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:31:16 +02:00
Tobias GesellchenandClaude Sonnet 4.6 251221cafa fix(handlers): remove stale account entry when MoveDevice target dir exists
When handleDiscoveredDevice calls MoveDevice and the target device
directory already exists (pre-existing duplicate state), os.Rename
fails with ENOTEMPTY/EEXIST leaving the stale source account entry
on disk. Because SaveDeviceInfo has just written fresh data under
accountID, it is safe to unconditionally remove the stale source
entry afterward — RemoveDevice returns nil when the path is already
gone (successful rename), so this is a no-op in the happy path and
a cleanup in the failure path.

Adds TestHandleDiscoveredDevice_CrossAccountMigration_TargetExists
which seeds a device under two real accounts (old sorts alphabetically
first so findExistingDeviceInfoByDeviceID picks it as storedAccount),
triggers discovery with the new account as MargeAccountUUID, and
asserts that after the cycle only the new account entry exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:23:38 +02:00
Tobias GesellchenandClaude Sonnet 4.6 109b9afa0c test(handlers): add cross-account migration test for handleDiscoveredDevice
Exercises the branch in handleDiscoveredDevice where a device's live
MargeAccountUUID differs from its stored account.  The test:

- seeds a device + presets under 'default'
- mocks /info to report a different margeAccountUUID ('8637922')
- calls handleDiscoveredDevice
- asserts the device is now stored under the new account with the live name
- asserts the old 'default' entry is gone
- asserts presets survived the MoveDevice rename
- asserts ListAllDevices returns exactly one entry (no duplicates)

Closes the server-level gap noted during PR #348 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:23:38 +02:00
Tobias Gesellchen 439b2cb9fc TODO We need to ensure that ids here are consistent with the ones used in the AfterTouch service. 2026-05-24 10:23:38 +02:00
Marcin Mennemann 44ad0e5928 code style: linting 2026-05-24 09:52:02 +02:00
Marcin Mennemann 65b142881a replace copy-and-delete migration with atomic MoveDevice 2026-05-24 09:52:02 +02:00
Marcin Mennemann 7a268a0372 fix: removing stale devices from datastore 2026-05-24 09:52:02 +02:00
Tobias GesellchenandClaude Sonnet 4.6 fbc96c0c01 fix(cli): make --service-url required, remove default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9172072601 feat: add source removal — health check, API endpoint, and CLI commands
Health check (checks_stale_internet_radio.go): detects stub INTERNET_RADIO
sources (empty credentials) left on devices initialised before the stub was
removed from the default source list. Quick-fix removes by ID; skips any
INTERNET_RADIO source that has real credentials.

Datastore: DeleteSourceByID and DeleteSourceByType (uniqueness-guarded).

API: DELETE /setup/sources/{account}/{device}/{sourceID}

CLI — two new commands:
  soundtouch-cli cloud source remove --service-url ... --account ... --device ... [--id 10002 | --type INTERNET_RADIO]
    Talks to AfterTouch (service side). --type resolves to canonical ID
    locally; fails for unknown types.
  soundtouch-cli source notify-updated --host <speaker-ip>
    Talks to the speaker directly. Fetches device ID from /info, then
    POSTs sourcesUpdated to :8090/notification so the speaker re-fetches
    its source list immediately.

CloudCommonFlags (--service-url / AFTERTOUCH_URL) mirrors CommonFlags
(--host) for AfterTouch-facing command groups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c305d22de0 refactor(datastore): drop INTERNET_RADIO from initial Sources.xml
Add getInitialSources() that excludes the legacy INTERNET_RADIO (10002)
provider from newly-created device Sources.xml files. GetDefaultSources()
retains the entry for backward-compatible canonicalisation of existing
devices and cloud-level account responses.

Fix mergeDefaultSources() to rebuild the merged list in canonical ID
order (defaults first, using stored credentials when present, then
custom sources such as Spotify). This prevents INTERNET_RADIO from
landing at the end of the cloud /sources response when a device's
Sources.xml was created without it.

Drop the two verbose search-loop log lines from resolvePresetSource.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 22:45:15 +02:00
Tobias GesellchenandClaude Sonnet 4.6 96e2c2e3cf fix(web): restore SourceAccount guard in HandleDevicePlay
The guard was accidentally placed in HandlePlayRadioBrowser instead of
HandleDevicePlay in the initial fix commit, then removed from there by
the build-fix commit — leaving HandleDevicePlay with no guard at all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:03:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ca466ec2a3 fix(web): remove stray SourceAccount guard from RadioBrowser handler
The previous edit accidentally inserted the TUNEIN placeholder guard
into HandlePlayRadioBrowser, which uses a different req struct without
SourceAccount/Source fields, breaking the build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:03:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 abe9079382 fix(web): strip placeholder SourceAccount before replaying recents
Speakers echo back the source name as SourceAccount when no real
credential is set (e.g. SourceAccount="TUNEIN" for a TUNEIN source).
HandleDevicePlay was forwarding this verbatim, causing the speaker to
try authenticating with the source name as a TuneIn account and
returning INVALID_SOURCE.

Clear SourceAccount when it equals Source; preserve it when it differs
(real credentials such as Spotify or STORED_MUSIC UUIDs).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:03:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 11a6515f4d feat(tunein): add section-grouped results and load-more pagination
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.

- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
  itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
  (not nested containers), and maps Station/Program/Topic items using
  the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
  matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
  state; each section shows a header label and a Load more button when
  a cursor is available; browse/navigate mode is unaffected

Relates to #336.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:25:01 +02:00
Tobias GesellchenandClaude Sonnet 4.6 38c771ad75 fix(web): skip auto-discovery on page load when periodic discovery is disabled
When `discovery_enabled` is false the page no longer fires a discovery
scan on load. Both DOMContentLoaded handlers now await fetchSettings()
and gate triggerDiscovery() on the returned flag — default true keeps
existing behaviour for installations that never touched the setting.

Also renames the UI label from "Enable Automated Discovery" to
"Enable Periodic Discovery" to make clear the checkbox controls the
background timer, not the manual trigger button or IP-entry form.

Relates to #269

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:19:46 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1eb1fefc1d fix(datastore): parse legacy <ContentItem> (capital C) in Presets.xml
encoding/xml is case-sensitive, so Presets.xml files written by older
AfterTouch versions using <ContentItem> (capital C) had all source,
location, and type attributes silently dropped on read. Every preset for
such a device had empty fields, causing mapPresetsToFullResponse to skip
them all — the speaker received /full with zero presets and stored nothing.

Fix: normalise <ContentItem> → <contentItem> before unmarshaling in the
new readPresetsLocked helper. If normalisation was needed, GetPresets
rewrites the file in canonical form after releasing the read lock, so the
issue self-heals on first service start with no manual intervention.

Diagnosed via the i218 encrypted diagnostic export (device 304511B46CBC,
ST30 Master Bedroom): health check speaker_presets_count reported
"Speaker shows 0 preset slot(s); service Presets.xml has 6", and the
service log showed six [Marge] /full: skipping preset N — source ""
messages per /full call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:08:51 +02:00
Tobias Gesellchen b3bab622cd Bump 2026-05-23 01:30:40 +02:00
Tobias GesellchenandClaude Sonnet 4.6 93248659a5 fix(tls): also cover derived OAuth subdomain in served cert SAN list
#337's first commit added the OAuth-derivation to the DNS interceptor
but missed the served TLS certificate. With a serverURL of
`http://mac.fritz.box:8000` the cert SAN list covered `mac.fritz.box`
but not `macoauth.fritz.box`, so the speaker would resolve the OAuth
host correctly (via the new DNS hijack) and then immediately fail the
TLS handshake — Spotify / Amazon Music token refresh dies before
reaching AfterTouch.

getDomains now calls discovery.DeriveOAuthHostnames(serverURL) and
discovery.DeriveOAuthHostnames(httpsServerURL), feeding the derived
names into the SAN map alongside the existing entries. IP-based
serverURLs continue to produce no derivation (the OAuth construction
is unrecoverable for them — see the existing oauth_target_reachable
health check).

Tests in cmd/soundtouch-service/main_test.go lock in:
  - Hostname serverURL → derived OAuth variant present in SAN list.
  - IP serverURL → no malformed `192oauth.…` entry leaks in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 90a8bb25cf fix(docs): update docs/README.md after archive moves
The markdown-link-check CI step caught five stale links in
docs/README.md's Concept Documentation section pointing at files
the previous commit moved into docs/archive/. Replaced with a
pointer to SUMMARY.md's Concepts section + a short curated list
of the currently-relevant docs (Spotify Overview, Spotify OAuth,
Amazon Music OAuth, Encrypted Export, Request Recording). The
archived planning artefacts get a single line acknowledging
their existence under docs/archive/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 6861063935 feat(dns): auto-derive OAuth subdomain from serverURL hostname (#337)
The speaker firmware constructs the OAuth host by appending "oauth" to
the first label of the configured streaming hostname (aftertouch.lan
→ aftertouchoauth.lan, used by both Spotify and Amazon Music token
refresh). AfterTouch's DNS server previously only hijacked the
hardcoded list of Bose hostnames, so operators self-hosting at a
custom hostname had to add the OAuth alias themselves — and the
amazon-music-oauth.md / spotify-overview.md docs incorrectly
claimed the DNS server handled it automatically.

ofthesun9 (#337) caught this via the worst variant: IP-based
serverURL (192.168.0.30 → 192oauth.168.0.30), which is a malformed
hostname no DNS resolver can answer for. There is no clean DNS
workaround for the IP case — the operator must use a hostname.

Three changes:

- pkg/discovery/dns.go DeriveOAuthHostnames parses the configured
  serverURL, derives <first-label>oauth.<rest> when the host is a
  hostname (not IP), and adds it to the DNSDiscovery hijack list. IP
  serverURLs deliberately yield no derivation — the malformed name
  isn't worth handling and the new health check surfaces the trap.
- New checks_oauth_target health check fires a Warning when serverURL
  is an IP literal, with a concrete example of the malformed name
  (`192oauth.168.0.30`) and a ManualCommand pointing at the switch.
- amazon-music-oauth.md and spotify-overview.md rewritten: drop the
  false "automatic" claim, document the three resolution paths
  (AfterTouch DNS + speaker resolves via it / external LAN DNS /
  per-speaker /etc/hosts), and explicitly flag IP-based --server-url
  as incompatible with OAuth on either provider.

Tests cover the derivation matrix (hostname / IPv4 / IPv6 / single
label / empty / garbage URL), shouldIntercept's new behaviour
(derived host hit, base host not auto-hijacked, case-insensitive),
the health check's four states, and the malformed-host helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1421ad5ce1 chore(docs): widen docs-consistency test, archive stale concept docs
The TestDocsConsistency walk only iterated [".", "guides", "reference",
"analysis"] — concepts/ was silently invisible, which is why
amazon-music-oauth.md slipped into the tree without a SUMMARY entry.

Refactored to walk the entire docs/ tree, with a small dirsToSkip
allow-list (_includes, archive, diagrams, images) for asset trees.
New top-level narrative directories are picked up automatically;
only asset dirs need an explicit entry.

The wider walk surfaced six previously-hidden concepts/* files. Five
older planning artefacts ("Enhanced State Management System",
"Upstream Bose Service Simulation") moved into docs/archive/ where
the dirsToSkip already excludes them; concepts/README.md renamed to
upstream-service-simulation-overview.md since "README.md" inside
archive/ would be misleading. Spotify Overview and Amazon Music
OAuth are user-facing narrative docs and are now linked under
Concepts in SUMMARY.md.

Note: concepts/streborn-patterns.md is internal review notes (its
own opening line says so) and is currently unlinked from SUMMARY.md;
will be handled separately by the maintainer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 00:34:36 +02:00
Tobias Gesellchen 56ace2f960 Update screenshots 2026-05-22 22:03:25 +02:00
Tobias GesellchenandClaude Sonnet 4.6 89bfa8c2fb feat(discovery): quiet per-packet logs by default; CLI keeps verbose
Discovery cycles emit one line per UPnP M-SEARCH header, one per
parsed response, and one per enrichment step — by default. A typical
service-binary cycle prints ~50–80 lines for a 3-speaker LAN. Most
operators want a startup-and-summary view; the per-packet trace is
only useful for debugging.

- New SetVerbose/IsVerbose/logVerbose helpers in pkg/discovery (atomic
  bool, zero-value off).
- Chatty log.Printf calls in upnp.go and mdns.go demoted to logVerbose:
  per-header dumps, per-response dumps, per-device enrichment steps,
  M-SEARCH details, read-deadline / cancel-context noise.
- Kept at default level: discovery start ("Starting SSDP discovery
  for…"), end ("Discovery completed. Processed N responses, found N
  unique devices" + per-device summary), warnings ("Configured
  interface not found", "Failed to fetch device description", …), and
  the new "Rejecting non-Bose device" classifier.
- cmd/soundtouch-cli/discover devices grew a --verbose / -v flag that
  flips the package toggle on; the service binary leaves it at the
  zero value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:15:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 1cd4226f5b feat: tighter discovery filter + UX cleanups (#269, #345, #355, #359)
Four small, independent improvements bundled into one cut:

1. Restrict device discovery to SoundTouch-family services (#269/#359).
   - mDNS now queries all three SoundTouch service-type variants in
     parallel (_soundtouch._tcp, _bose-soundtouch._tcp, _soundtouchstick._tcp)
     and deduplicates results by host:port. mDNS has no native wildcard
     for service types, so we fan out one query per variant.
   - UPnP/SSDP M-SEARCH receives a manufacturer/modelName check after
     fetching the device description: devices whose manufacturer doesn't
     contain "bose" AND whose model doesn't contain "soundtouch" are
     rejected. Closes the loop on NorbertBauer's diagnostic bundle that
     showed a Dreambox dm920 and Onkyo HT-R695 living under the default
     account because they answered our generic MediaRenderer:1 probe.

2. New health check: default-account-contains-non-Bose-devices (#269).
   Walks devices keyed under data/accounts/default/devices/, flags any
   whose ProductCode/Name doesn't look SoundTouch, and offers an Evict
   QuickFix. Bose devices still in default (legitimate pre-pair) are
   intentionally ignored — that's the consistency check's domain.

3. Clipboard fallback for Copy buttons (#355). The two health-tab Copy
   buttons used navigator.clipboard.writeText, which requires a secure
   context. Over plain HTTP at a LAN IP the browser blocks it silently
   and the button shows "Copy failed". New copyTextToClipboard helper
   tries the modern API first, falls back to document.execCommand("copy")
   via an off-screen textarea.

4. Web UI static-asset cache-busting (#345). dekiesel needed Ctrl+F5 to
   see the v0.89 Download button after upgrade. The root HTML now
   carries a ?v=<hash> query string on /web/js/script.js and
   /web/css/style.css references. Hash is sha256 over the embedded asset
   bodies, truncated to 12 hex chars — stable per binary, changes when
   the assets change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:15:34 +02:00
Tobias GesellchenandClaude Sonnet 4.6 dbe123226c docs(ui): move diagnostic export block above findings list
When the findings list grows the diagnostic-export subsection got
pushed below the visible viewport. Moving it above the findings list
(but below the Refresh header and description) keeps the Download
button in reach regardless of how many checks fire.

Wrapped in a subtle gray box to visually distinguish it from the
checks themselves.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 d6302985f4 docs(ui): group diagnostic-report button with its explanation
Previously the Download button sat at the top-right with the Refresh
button, while the "What does the report contain?" details block lived
below the health-checks description paragraph — visually separated by
the description and an entire section's worth of layout.

Now the diagnostic export lives in its own subsection at the bottom of
the Health tab, with the button, a one-line tagline, the details
block, and the post-download status indicator all adjacent. The
header keeps just Refresh, which controls the health-checks view it
sits next to.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3d1eed6b65 docs(ui): make TLS extra hosts section answer "do I need this?" first
Previous text explained how the merge works but didn't give operators a
clear signal for when to act. New structure leads with:

- "When you need this": rarely; symptoms a user actually sees (presets
  reset, BoseApp offline) instead of a syslog string most users won't
  consult.
- "How to tell": open the Health tab, look for speaker_marge_url; if
  clean, leave this empty.
- "Manual path": only after the user has decided they need it.

Adds a small, always-visible hint below the label that points to the
Health tab — most operators won't expand the ⓘ panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3a0b30bc33 feat(tls): persist TLSExtraHosts + Settings UI + speaker_marge_url QuickFix
Operators who deploy AfterTouch on an IP-only host (no DNS hostname) and
who get a speaker_marge_url health warning previously had to SSH in, edit
their systemd unit or docker-compose, add --tls-extra-host, and restart.
The fix is now reachable from the UI:

- datastore.Settings gains TLSExtraHosts []string. At startup
  applyPersistedSettings merges CLI/env values (still authoritative)
  with persisted ones, deduplicating while preserving order.
- /setup/settings (GET) exposes tls_extra_hosts (editable list) and
  tls_san_hosts (the full effective SAN list, read-only).
- /setup/settings (POST) accepts tls_extra_hosts (*[]string so callers
  can distinguish "field omitted" from "explicitly empty").
- Settings tab grows a "TLS extra hosts" textarea + an info panel
  explaining the restart-required dance.
- speaker_marge_url emits a QuickFix labelled "Add <host> to TLS hosts"
  alongside the existing CLI manual command. The fix re-probes the
  device's /info, extracts the margeURL host, and appends it to the
  persisted list — race-safe against stale findings.
- HTTPS-SETUP.md documents both paths.

Tests cover: merge dedup + ordering + whitespace, the new QuickFix
emission shape, and the margeURL host extraction across HTTPS/HTTP/bare
input forms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 377fa9ceda feat(preflight): skip :443 check in HTTP-only deployments + OUTPUT-chain caveat
The :443 reachability preflight was emitting a WARN on every deployment
where AfterTouch's configured --server-url is HTTP (not HTTPS), even
when speakers were migrated to that HTTP URL and never connect to :443.
Operators reproduced this on #218 (CTonyPeterson) and #344
(california444) — both saw the warning even though their setups had no
need for iptables port forwarding, and CTonyPeterson followed the
recommended iptables OUTPUT rule which then caught his host's own
outbound HTTPS traffic and broke `go install` and his browser.

Two changes:

- Probe443Result gains NotApplicable + Reason. Check443Reachability
  returns the NotApplicable verdict when the parsed serverURL scheme is
  http. The settings UI renders an ℹ️ info badge with the reason instead
  of a red ✗.
- FormatPreflightGuidance grows a one-line caveat about the iptables
  OUTPUT chain: it catches all outbound :443 on the host, including
  browsers / go install / apt-get, which is rarely what the operator
  wants.

HTTPS-SETUP.md gains the same caveat plus a section documenting the
new not-applicable verdict.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:44 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a1754a4500 feat(setup): remote_services CLI integration
- setup remote-services subcommand: enables (default) or removes
  (--remove) the remote_services SSH-enablement marker via SSH, targeting
  persistent locations (/etc or /mnt/nv) before the volatile /tmp fallback
- setup plan now includes a "persist remote_services" step when the marker
  is only in /tmp (would be lost on next reboot, breaking SSH mid-migration)
- setup plan state header shows a [⚠] line when remote_services is
  enabled but not persistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:09:41 +02:00
Tobias Gesellchen 722b2ca9a6 lint 2026-05-22 19:04:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2ec5efde7f feat(setup): dual DNS preflight check — CLI and speaker perspectives
Replaces the single-sided requireAfterTouchDNSReachable with runDNSPreflight
that probes both the CLI machine and the speaker (via SSH nslookup) in
parallel, then renders a two-row table when results differ.

The speaker's perspective is authoritative: a CLI-only failure no longer
blocks the migration (the speaker may reach the DNS listener via a network
path the CLI host cannot). Migration is only aborted when the speaker itself
definitively cannot reach AfterTouch's DNS listener.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:04:13 +02:00
Tobias Gesellchen ff61f0ca65 lint 2026-05-22 18:58:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 b0df8ba963 fix(setup): correct false-positive migration detection and plan command errors
- isXMLMigrated and isResolvConfMigrated now guard against empty hostname
  (Go's strings.Contains(s, "") is always true, causing any speaker to
  appear migrated when --service-url has a malformed single-slash scheme)
- renderPlanSteps message no longer claims "and paired" when --include-pair=false
- validateServiceURL rejects malformed service URLs early with a hint
  (e.g. "did you mean https://soundtouch.fritz.box?")
- Generated plan-step commands move --host before the subcommand name
  (urfave/cli/v2 requires global flags before the first subcommand token)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:58:57 +02:00
Tobias Gesellchen a684c88325 Bump 2026-05-22 18:55:48 +02:00
Tobias GesellchenandClaude Sonnet 4.6 915fca496f feat(export): improve diagnostic report web UI
Add a <details> block listing what the encrypted archive contains so
reporters know what they're sharing before clicking. After a successful
download, show two submission options with email preferred:
aftertouch-support@gesellix.net (mailto link with pre-filled subject and
filename) or a GitHub issue with the file renamed to <filename>.txt (GitHub
blocks .age uploads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 18:39:20 +02:00
dependabot[bot] 95979137af ci(deps): bump docker/setup-buildx-action in the setup-actions group
Bumps the setup-actions group with 1 update: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).


Updates `docker/setup-buildx-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: setup-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:38:53 +02:00
dependabot[bot] ea1e5f3794 ci(deps): bump docker/build-push-action from 7.1.0 to 7.2.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.1.0 to 7.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/bcafcacb16a39f128d818304e6c9c0c18556b85f...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:38:31 +02:00
dependabot[bot] ff0f3e1e66 ci(deps): bump docker/login-action from 4.1.0 to 4.2.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:38:21 +02:00
dependabot[bot] db37372393 deps(deps): bump the golang group with 4 updates
Bumps the golang group with 4 updates: [golang.org/x/crypto](https://github.com/golang/crypto), [golang.org/x/net](https://github.com/golang/net), [golang.org/x/image](https://github.com/golang/image) and [golang.org/x/sys](https://github.com/golang/sys).


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

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

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

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

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/image
  dependency-version: 0.41.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
- dependency-name: golang.org/x/sys
  dependency-version: 0.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: golang
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-22 18:36:02 +02:00
Tobias Gesellchen abfe540864 chore: update default version to v0.89.0 2026-05-21 23:40:00 +02:00
Tobias GesellchenandClaude Sonnet 4.6 3cfb3da498 feat(export): encrypted diagnostic report for issue reporting
Adds a "Download diagnostic report" button on the Health tab that
produces an age-encrypted .age file the user can attach to a GitHub
issue without exposing sensitive data.

Archive contents (tar.gz, then age-encrypted with the maintainer's
SSH ed25519 public key):
- diagnostic.json         structured health/device summary (no secrets)
- datastore/…/*.xml       raw on-disk XML verbatim for diff vs HTTP
- http/service/…          live service HTTP responses per account/device
- http/speaker/…          live speaker API responses (port 8090)
- ssh/speaker/…           CA bundles + logread (last 20 min, 127.0.0.1
                          filtered) + dmesg fetched via SSH
- system/ca.pem           service CA cert
- system/resolv.conf      host DNS resolver config
- settings.json           service settings (OAuth secrets redacted)
- env.txt                 filtered process environment
- logs/service.txt        in-memory service log buffer

Supporting tooling:
- scripts/setup-diagnostic-key.sh  one-time SSH key-pair generation
- scripts/decrypt-diagnostic.go    go run helper for maintainer decryption
- keys/public/diagnostic.pub       committed public key (matches github.com/gesellix.keys)
- docs/DIAGNOSTIC-EXPORT.md        maintainer setup + user workflow guide
- docs/concepts/ENCRYPTED-EXPORT.md  research notes and architecture rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 20:02:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9312e27019 feat(health): operator-confirmable QuickFix to complete speaker pairing (#329)
Closes the loop on the empty-<margeAccountUUID> finding from
RegisterSpeakerInfoReachable. Operators in #329 quoted that finding
verbatim and asked "What is the recommended way to complete pairing?"
— the framework detected the condition but offered no in-UI recourse.

The QuickFix completes pairing in-place by dispatching through
setup.Manager.PairAccount, which tries HTTP /setMargeAccount first
and falls back to telnet `envswitch accountid set` — same code path
the existing POST /setup/pair-account/{deviceId} handler uses.

Account ID is picked at finding-time when a real (7-digit) account
directory already contains this device on disk (typical scenario:
AfterTouch remembers a previous pairing the speaker forgot). When
no such account exists, the executor generates a fresh 7-digit ID
via setup.GenerateAccountID at click time. Either way, the chosen
ID is named in the Confirm dialog and the CLI ManualCommand
fallback so the operator can see what's about to happen.

Architecturally: the FixID constant lives in the health package
alongside the check that emits the finding, but the executor is
registered from handlers/server.go where setup.Manager is
available. This keeps the health package's transitive dep surface
small (the boundary comment near speakerInfoXML deliberately
forbids importing setup, which would pull SSH/telnet/certmgr).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:44:57 +02:00
Tobias GesellchenandClaude Sonnet 4.6 2b50ef0c98 fix(datastore): prefer named default entry when two default dirs collide in ListAllDevices
When the same device appears under `default/` in two separate data dirs
(e.g. primary DataDir and the legacy st-go/data path), the first-seen entry
was kept unconditionally even when it had an empty name. A subsequent
default entry carrying a real name was silently dropped, causing name loss
in SyncFromAccountFull.

Addresses TestReproduceMissingName regression introduced by the
dedup-default-last change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 fa2f7cd17d feat(health): confirm orphan-account deletion against speaker /info
The orphan-account QuickFix used to rely solely on the operator's
manual log inspection ("Before deleting, verify the speaker isn't
currently PUTting to account X") plus the Confirm dialog. Adds a
defensive layer: the speaker itself answers "which account do I
belong to?" via :8090/info's <margeAccountUUID> element. Wire that
into both ends of the flow.

Detection (consistency check): on each scan we probe /info for each
device with a known IP. When the speaker answers, its
margeAccountUUID overrides the on-disk ListAllDevices guess, and the
finding's Details/Confirm copy quotes the speaker verbatim — "Speaker
/info reports margeAccountUUID=1111111; this directory (account
9569497) is stale because the speaker has stopped targeting it." If
the probe fails the wording falls back to the manual-verify hint.

Executor (deleteOrphanAccountEntry): re-probes /info before deleting
and refuses when the speaker reports target.Account as live. That
closes the race where the operator re-paired between scan and click.
Logs every successful probe + decision for auditability.

fetchSpeakerMargeAccount split into a URL-injectable variant so the
httptest-driven tests can verify the probe end-to-end without
hard-coding :8090 onto an unreachable address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 884e19c791 feat(health): operator-confirmable QuickFix to reassign canonical source IDs
og-gh's #343 reproducer is built-in radio sources sitting on
non-canonical IDs (the 2000001+i fallback that GetConfiguredSources
hands out when on-disk sources lack canonical IDs). After re-pair
churn, presets binding by <sourceid> end up rebound to whichever
source happened to get the colliding numeric ID — silently rewriting
e.g. a TUNEIN preset to RADIOPLAYER on the next /full fetch.

The strict-match commit (aa449fb) keeps that drift from corrupting
emission downstream, but the underlying Sources.xml is still wrong
and the operator has to either pull-from-speaker (online) or
hand-edit XML (tedious). This commit adds an offline QuickFix that
rewrites the source IDs in Sources.xml back to canonical
(TUNEIN→10004, INTERNET_RADIO→10002, LOCAL_INTERNET_RADIO→10003,
RADIO_BROWSER→10005) and updates every <sourceid> reference in
Presets.xml/Recents.xml in lockstep.

Skipped when the canonical ID is already in use by another source
(e.g. duplicate TUNEIN entries from manual XML editing) — collisions
need operator review. Idempotent: a second click is a no-op when
everything is already canonical.

The fix is reachable from the consistency check finding, gated by
the framework's standard Confirm dialog which enumerates the exact
ID rewrites before executing. No speaker contact required; the
speaker re-fetches /full on its own and picks up the new IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 99d7111514 feat(health): operator-confirmable QuickFix to delete orphan account dirs
The orphan-account-entry finding (introduced in 0ac140f) currently just
points the operator at a copy-pasteable rm -rf command. Adds a
QuickFix button that does the same delete in-process after the
operator confirms via the standard health-framework Confirm dialog.

Findings are now one-per-(stale_account, device) pair so each delete
button targets exactly one directory. The Confirm copy spells out the
full path being removed and reminds the operator that the active
account isn't touched. The companion ManualCommands entry keeps the
shell-side rm available for operators who prefer to run it themselves.

deleteOrphanAccountEntry refuses on missing account/device, errors
explicitly when the directory was already cleaned up by hand, and
logs every successful removal so the action is auditable from the
service log.

The framework gates the click on Confirm — destructive operations
need operator consent per CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 8604f1e6ba fix(datastore,health): enumerate all stale account dirs per device
User reported "we might have another issue with the account mapping"
after the prior commit only handled the default-vs-real case. The
backup at /backup/var_20260520_01 showed device A81B6A536A98 living
under four directories — accounts/9569497, accounts/default,
accounts/1111111, and the top-level default/ — only the third of
which currently receives the speaker's PUTs.

The authoritative "which account does this device belong to" signal
is the URL of the speaker's incoming PUT (per "speaker decides"),
which only the live handler observes. mtime is a proxy and can be
fooled by backup tools, manual touches, etc., so this commit drops
the mtime tiebreaker the previous attempt added.

Instead:
  - ListAllDevices' dedup keeps default-deprioritisation (clear
    placeholder semantics) but otherwise picks the first real account
    encountered in stable alphabetical order. No heuristic guessing
    among real accounts.
  - New AllAccountsForDevice(deviceID) enumerates every on-disk
    account directory containing the deviceID.
  - The consistency check's orphan finding now lists every stale
    account dir for each device, with the path the operator needs to
    inspect and a pointer to the service log so they can verify which
    account the speaker is actually targeting before deleting
    anything.

We don't delete automatically — destructive filesystem actions need
explicit operator consent (CLAUDE.md "destructive actions" rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e6954eed60 fix(datastore): real account wins over "default" placeholder in dedup
ListAllDevices used to let an entry under accounts/default/devices/<id>
replace the real-account entry for the same physical device whenever
the default-side DeviceInfo.xml had a non-empty <name>. The consistency
check then reported the device under "account default" even while the
speaker was happily POST/PUT'ing to its actually-paired account — the
operator saw "preset slot 1 present on speaker but missing from
service" for slots that very obviously did exist, just under the real
account they couldn't see.

The dedup now treats "default" as a fallback placeholder: sorts it to
the back of the iteration, and never lets it replace a real-account
entry. A default-only device (fresh discovery, never paired) is still
returned exactly as before.

Also adds an orphan-detection finding in the consistency check that
walks accounts/default/devices/ directly and flags entries whose
deviceID is also paired under a real account, with a copy-pasteable
rm -rf hint. We don't delete automatically — destructive filesystem
actions need explicit operator consent (CLAUDE.md).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 97238eb07a feat(marge): log GH-343-shaped source mismatch on UpdatePreset
The speaker's preset PUT carries only <sourceid> — no symbolic source
name — so we can't strict-match at write time the way we do on /full
emission. Adds a diagnostic-only inference from the preset's location
URL pattern (/v1/playback/station/sNNN -> TUNEIN, /playback/container/
-> SPOTIFY, /custom/v1/playback/ -> LOCAL_INTERNET_RADIO) and logs
when the inference disagrees with the bound source's SourceKeyType.

This is visibility, not enforcement: the binding still proceeds as
the speaker requested (per "speaker wins"). The log gives the operator
a concrete pointer — "the URL looks like TUNEIN but I bound to
RADIOPLAYER, your Sources.xml may be stale, try setup.syncSources" —
instead of leaving them to discover the drift via the consistency
check days later.

URL inference is deliberately fuzzy and one-way: it only triggers a
log when confident, returns "" otherwise, and never feeds the
binding decision. That keeps it from re-introducing the guesswork
the user pushed back on for the actual GH-343 fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 f0a63f19f4 fix(datastore): self-heal legacy Audio leak on read, preserve speaker intent
The pre-fix marge.syncPresets / syncRecents path persisted the upstream
cloud's <source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source. That value doesn't match what the speaker writes
via its own /presets endpoint (which is the source of truth), and one
operator's consistency-check scan surfaced ~50 recent_mismatch findings
all tracing back to this single leak.

GetPresets / GetRecents now repair the leak on load: when persisted
Source is "Audio" (or empty) AND SourceID resolves in the current
Sources.xml, substitute the speaker-perspective SourceKeyType. The
repair fires only on the *leak signature* — when persisted Source
carries a non-leak symbolic value like "TUNEIN", we never touch it.

That asymmetry is load-bearing for GH-343: a TUNEIN preset whose
SourceID has been re-classified to RADIOPLAYER in Sources.xml stays
TUNEIN here. The speaker's previously-stored intent wins over a stale
current source-list entry — soundcork's blind matching_src.source_key_type
substitution is the silent rewrite we're protecting against.

Also:
  - sourceKeyTypeFromFullSource now logs when the providerid isn't
    canonical and we fall back to upstream Type, so future leak
    signatures are visible instead of silent.
  - Removes the loadServiceView workaround that resolved Source via
    SourceID at consistency-check time — datastore now repairs at
    the layer where every consumer benefits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9fafe9f960 fix(marge): syncPresets/syncRecents persist speaker-perspective Source
marge.syncPresets / syncRecents were writing the upstream cloud's
<source type="Audio"> attribute into ServicePreset.Source /
ServiceRecent.Source on disk. That's a protocol-level classification,
not the symbolic name the speaker itself uses (TUNEIN, INTERNET_RADIO,
…). The on-disk shape ended up disagreeing with what the speaker writes
via its own /presets endpoint, which IS the source of truth — and the
disagreement surfaced as cross-side mismatches in the new consistency
check (one user saw 30+ recent_mismatch findings, all "speaker source=X
vs service source=Audio").

Project the upstream FullResponseSource back to the speaker's
perspective at persist time via SourceProviderID lookup against
StaticProviders (the inverse of canonicalProviderIDByID). Falls back
to the upstream Type for unknown providerids so non-canonical sources
stay no-worse-than-before.

The consistency-check workaround in loadServiceView (which resolves
Source via SourceID lookup on read) stays in place to cover legacy
on-disk data written by the previous behaviour — that data only gets
cleaned up when the operator re-runs setup.syncPresets from the
speaker directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ce2935a4bd fix(health): consistency report — cut noise, fix Audio leak, group unsynced
First operator run of the new consistency check surfaced both real bugs
and a lot of noise. This commit refines the report so the remaining
findings are actionable.

Real bugs fixed:

- loadServiceView now resolves preset/recent Source via SourceID lookup
  against Sources.xml, instead of trusting the persisted Source field.
  syncPresets / syncRecents in sync.go currently writes the upstream
  FullResponseSource.Type ("Audio") into ServicePreset.Source, which
  made every cross-side mismatch finding read "service source='Audio'".
  Underlying syncPresets/Recents misfeature is a separate fix; the
  consistency check stops being fooled by it.

- Duplicate-source dedup keyed by type+account, not just type.
  SpotifyConnectUserName + SpotifyAlexaUserName, QPlay1UserName +
  QPlay2UserName are legitimate sub-accounts of the same source type
  and used to falsely trip duplicate_source warnings.

Noise removed:

- Cross-side source_mismatch comparison dropped. Speaker /sources
  enumerates local I/O sources (AUX, BLUETOOTH, AIRPLAY, QPLAY, …),
  service Sources.xml tracks credentialed streaming sources (TUNEIN,
  INTERNET_RADIO, …). They legitimately don't overlap on most types,
  so the asymmetry was pure noise.

- Internal-consistency check restricted to the service side. Streaming
  sources are never in the speaker's /sources by design (they're
  proxied through BMX), so a TUNEIN preset on the speaker always
  looked "dangling" against speaker /sources.

- Service-only / speaker-only recent cascade collapsed into one
  summary line when 5+ speaker recents are missing from service.

- New short-circuit: when the service has nothing (presets, recents,
  sources all empty) for a device the speaker clearly has state for,
  emit one "this device looks unsynced, click Sync" warning instead
  of dozens of per-slot mismatches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 c47cf81a93 feat(health): cross-reference presets/recents/sources consistency check
Adds a new health check that surfaces preset / recent / sources
inconsistencies operators previously had to dig out by hand. For every
paired device, the check runs three analyses:

1. Service-side internal consistency. Verifies every Presets.xml and
   Recents.xml entry's <sourceid> resolves to a Sources.xml entry, and
   flags duplicate source-type entries (mapPresetsToFullResponse picks
   the first match, so duplicates can mask GH-343-style cross-type
   binds).

2. Speaker-side internal consistency. Same analysis applied to the
   speaker's :8090 XML — catches the case where the speaker locally
   knows a TUNEIN preset but the speaker's /sources list doesn't
   advertise TuneIn (a #253-class trigger).

3. Cross-side comparison. Speaker vs service per slot / per recent /
   per source type. A preset whose source attribute disagrees between
   sides is flagged with both values in the detail — that's the
   GH-343 footprint after a reboot, and now it shows up as a Finding
   instead of a forum thread.

Speaker probes fail gracefully with a copy-pasteable curl block; the
service-side internal check still runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 5ff72f2af8 fix(marge): strict-match preset/recent source by type, refuse cross-type binds
GH-343: a TUNEIN preset surviving a reboot used to come back from /full
re-attributed to RADIOPLAYER because mapPresetsToFullResponse's step-1
exact-ID match accepted any source with the matching numeric ID,
regardless of what the preset originally claimed for its Source. The
speaker trusts /full as ground truth, so the local preset got its
source attribute silently rewritten.

Tighten step-1: refuse the bind when the preset's claimed Source and
the configured source's SourceKeyType disagree (both populated). The
existing step-2 type/account fallback then finds the right source, or
synthesise/skip handles the no-match case. The refusal is logged so
the cross-type collision is visible in service logs.

Same fix applied to findMatchingSourceForRecent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:41:32 +02:00
Tobias GesellchenandClaude Sonnet 4.6 ccdc2bd6a4 fix(datastore): preserve speaker's isPresetable verdict in SavePresets
SavePresets hard-coded isPresetable="true" on every persisted preset,
overwriting the speaker firmware's verdict. The speaker sets
isPresetable="false" for content it can't independently recall later
(notably Spotify Connect pushes from a phone — see GH-235); masking
that flag made the on-disk XML look valid while pressing the preset
on the speaker still did nothing, leaving users debugging a phantom
"stored but won't play" state.

Now preserve the caller's value and default to "true" only when it's
empty. A non-recallable preset is logged at info level so users can
tell from the service log why a stored preset isn't playing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 9f8c1cf536 fix(marge): mirror skip-or-synthesise into mapRecentsToFullResponse
Recents had the same protobuf-required-field hazard as presets — an
empty <source/> block inside <recent> would also abort the speaker's
/full sync (the recents poisoned-sourceproviderid regression
documented this once for a related sub-symptom). Apply the same
skip-or-synthesise filter so an orphaned recent can never take the
whole account sync down.

The synthesise/skip code paths log at info level; same visibility
posture as the preset side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 22f60459ba fix(marge): auto-add canonical sources on UpdatePreset, accept Stockholm <username>
UpdatePreset returned "invalid account/source" with a 500 when the
speaker's preset PUT referenced a source that wasn't in AfterTouch's
per-device configured-sources list. After a factory reset the speaker
locally knows the built-in radio sources but AfterTouch's Sources.xml
may not, so a long-press appeared to succeed on the speaker but the
preset was never persisted — and the next /full sync wiped the local
copy. Closes GH-314 (and the underlying trigger described in GH-253).

For the canonical built-in IDs (10001..10005) AfterTouch now auto-adds
the source from the same template post-pair would have used, then lets
the preset land. Non-canonical / account-bound IDs (Spotify "100004",
Amazon, custom) are still rejected — we can't fabricate per-account
credentials. The rejection now logs the diagnostic context so users
don't have to grep source to understand why their long-press didn't
stick.

Also accepts the Stockholm mobile app's <username> field as the preset
name when <name> is empty (soundcork documents the same divergence).

Every code path that silently repairs preset data now logs at info
level: synthesised /full source blocks, skipped presets, auto-added
canonical sources, and the Stockholm name fallback. This makes user
diagnostic dumps actionable without source-spelunking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:30:53 +02:00
Tobias GesellchenandClaude Sonnet 4.6 09ec332375 fix(marge): synthesise or skip presets with unresolvable sources in /full
When a preset on disk referenced a source no longer in the configured-
sources list, mapPresetsToFullResponse appended it with an empty
<source/> block. The speaker decodes /full as protobuf and treats the
inner source fields (id, type, sourceproviderid, credential) as
required, so the malformed block aborted the whole account sync and
wiped the speaker's locally stored presets — the GH-269 symptom of
"/presets empty within seconds of AfterTouch coming online".

For well-known radio providers (TuneIn, InternetRadio,
LocalInternetRadio, RadioBrowser) the preset now gets a synthesised
source block built from canonical defaults; account-bound providers
(Spotify, Amazon) are skipped with a log line so other presets in the
response survive the sync.

Also folds RADIO_BROWSER into resolveSourceName's fallback switch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:27:15 +02:00
Tobias Gesellchen e643495287 chore: update screenshots (v0.87.x) 2026-05-19 23:42:21 +02:00
Tobias Gesellchen 8513d90e34 chore: update screenshots 2026-05-19 23:41:26 +02:00
Tobias GesellchenandClaude Opus 4.7 ce3b0e582a fix(health): drop InsecureSkipVerify from cert-chain probe
CodeQL alert 147 flagged the Phase-2 re-dial with
InsecureSkipVerify=true, used to read the served leaf after
Phase 1's strict verification failed.

The leaf is already reachable without a second connection:
tls.CertificateVerificationError carries
UnverifiedCertificates, and the three x509.* verification-
error types each carry the offending Cert. errors.As over
those covers darwin (Security.framework) and linux
(crypto/x509) consistently.

Same three classifier outcomes
(leafFromOwnCA/leafSubjectEqualsIssuer/leafForeign), same
chainContext rendering — the classifier reads only the leaf,
which is byte-identical to the Phase-2 peers[0]. Removes the
only InsecureSkipVerify literal in the tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 b149580c19 fix(ding): clamp sample rate against int -> uint32 truncation
CodeQL flagged the writeWAV cast of strconv.Atoi's result to
uint32 (alert 148). Two-layer defence: the handler rejects
sample-rate query params outside [8000, 192000] before parsing
ever reaches Render, and WithDefaults snaps any out-of-range
caller-supplied SampleRate back to the default before
renderChirp allocates buffers sized by it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 4f6f4c497a fix(health): self-signed AfterTouch chain is INFO, not WARN
The previous classifier always returned SeverityWarning when the
served leaf didn't validate against the service host's system
trust store. For AfterTouch's *default* deployment shape (its
own self-signed CA), that's the expected, healthy state — the
service host's trust store deliberately doesn't include our CA;
speakers establish trust via `setup install-ca`, not via system
roots. Reporting it as a warning misled non-technical operators
into thinking something was broken.

Rework the severity matrix:

  - leafFromOwnCA (signature-verified): INFO. Message says
    "AfterTouch is serving its own self-signed CA chain
    (expected)". Details explain the service-host trust-store
    state is by design. Manual command becomes a reminder
    rather than a fix.
  - leafSubjectEqualsIssuer (heuristic): INFO. Explains the
    heuristic and offers both install-ca (if it is AfterTouch)
    and openssl (if it isn't) as paths.
  - leafForeign (genuinely unexpected): WARN. Unchanged
    semantics; this is the case that actually wants attention.
  - connection failure: ERROR. Unchanged.

Title renamed from "HTTPS endpoint certificate validates" (which
read as a binary assertion the finding contradicted) to
"HTTPS endpoint TLS configuration".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 8571595aef feat(health): add CA cert expiry check
Separate check from service_cert_chain: that one inspects what's
served right now, this one watches when the trust anchor itself
will stop being usable. Even when the served leaf validates,
the CA's NotAfter will eventually expire every leaf it has ever
issued — and every paired speaker would then need
`setup install-ca` again with a freshly generated CA.

Three thresholds against the loaded CA's NotAfter:

  > 90 days remaining   → no finding (rolls up to OK)
  31..90 days           → INFO, surfaces the renewal date so it
                          isn't a surprise
  1..30 days            → WARNING with regeneration guidance
  expired               → ERROR — speakers will reject leaves

ManualCommand renders the actual cert path from
certmanager.GetCACertPath() so operators don't have to guess
where to delete. Sibling .key path inferred from the cert path
basename — close enough for a copy-paste hint; operators verify
before running.

Rounded day arithmetic via (d + 12h) / 24h to avoid the
"expires in 59 days" surprise caused by ASN.1 GeneralizedTime
truncating sub-second precision on the CreateCertificate /
ParseCertificate round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 29d611f9c1 feat(health): aggregate device-summary panel on Devices tab
Audit item #1 (11+ recurrences in issues / discussions): pull
speaker /info + /sources + /presets, plus service-side state
and pairing inference, into one view per device.

Backend: GET /setup/device-summary/{deviceId} probes the three
speaker endpoints concurrently (sync.WaitGroup, 3 s per probe)
and merges the result with what the datastore knows for the
same device. Partial failures don't break the response — each
sub-section carries its own reachability + error + curl_command
so the UI can render copy-paste fallbacks when the service host
can't reach the speaker.

JSON shape covers four panels:
  - device      identity + firmware
  - speaker     {info, sources, presets} with raw outcomes
  - service     server URL, expected hosts, Sources.xml /
                Presets.xml presence and counts
  - pairing     paired flag, marge host, host match

UI: new "Inspect" button per row on the Devices tab. Clicking
expands a sibling row with five summary cards (info / sources /
presets / service / pairing). Each unreachable card renders the
matching curl command with a Copy button — same dual-mode
pattern as Health findings. Closes the gap operators were
filling by manually concatenating curl output across the three
speaker endpoints when filing bug reports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 cb81be3143 fix(health): translate wildcard/empty DNS bind into a dialable target
The DNS sanity check passed bindAddr directly to dns.Client.Exchange.
Wildcard binds like "0.0.0.0:53", "[::]:53", or the empty
string (which the dns lib treats as default port 53 on all
interfaces) aren't actually dialable from inside the same
host — net would refuse the empty string outright, and our
finding rendered "Queried ." in the operator's UI.

resolveDNSQueryTarget now translates:

  ""              → 127.0.0.1:53
  ":53"           → 127.0.0.1:53
  "0.0.0.0:53"    → 127.0.0.1:53
  "[::]:53"       → 127.0.0.1:53
  "192.0.2.10:53" → unchanged
  "53"            → 127.0.0.1:53
  "example.com"   → example.com:53

The finding's Details now exposes both the configured bind and
the effective query target separately, so when queries still
fail the operator can tell whether the server simply isn't
listening on a dialable address vs. responding with the wrong IP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 a59f71a6e1 docs(ding): document supported knobs + caching on HandleDing
Mirrors what I had in the conversation summary: parameter list
with types and defaults, the sync.Once cache behaviour for the
default-options request shape, a copy-paste curl example, and a
pointer to the renderer package + offline CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c90fdf234f fix(health): classify self-signed leaves via real CA signature check
The Subject==Issuer heuristic for "this is AfterTouch's
self-signed cert" misses the common case: AfterTouch's internal
CA has CN="SoundTouch Local Root CA" while leaves it issues have
CN="soundtouch" — different Subject and Issuer strings, so the
classifier was falling through to "foreign chain" and suggesting
openssl s_client when install-ca was actually the right fix.

Replace the heuristic with a definitive check: load AfterTouch's
own CA leaf via setup.Manager.Crypto.GetCACertPath() and call
x509.Certificate.CheckSignatureFrom(ca). When that succeeds we
*know* the leaf came from our own CA. The Subject==Issuer
heuristic stays as a fallback for environments where the CA
isn't loadable (with a clarifying note in the hint).

Server.loadOwnCACert caches the parsed CA via sync.Once so
repeated Health polls don't re-read the PEM.

Fixes the case shown in soundtouch.fritz.box deployments where
Subject=CN=soundtouch,O=AfterTouch and Issuer=CN=SoundTouch
Local Root CA,O=SoundTouch Local Service confused the
classifier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 147a69d1c3 refactor(ding): synthesise on demand instead of vendoring the WAV
Move the ding renderer into pkg/service/ding so it can run both
at request time (from the new HandleDing handler) and offline
(from the existing scripts/gen-aftertouch-ding CLI, now a thin
wrapper around the same package).

- GET /media/aftertouch-ding.wav synthesises on first call,
  caches the default-options bytes via sync.Once, and accepts
  query-string overrides for every knob (pitch-{high,mid,low},
  chirp-ms, gap-ms, attack-ms, release-ms, sample-rate, peak).
  Invalid / out-of-range values silently fall back to defaults.
- Embedded WAV is gone from VCS — no 52 KB binary in the
  repo, and tweaking the sound is now a query-param away rather
  than a regenerate-and-commit cycle.
- Health-tab playback_test check is unchanged: the URL it
  references (/media/aftertouch-ding.wav) keeps the same shape,
  the handler just produces the bytes dynamically now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 6356ca588b chore: gitignore SERVICE-HEALTH.md alongside NEXT/DONE
Companion working-tree note for the Health-tab debug-utility
programme. Same status as NEXT.md and DONE.md — session-local
plan/tracking artifact, not a project document.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 56efb4fcdb feat(health): add per-device "refresh sources" affordance
Standalone version of the sources-refresh trigger the
sources_xml_diff check emits opportunistically — exposed per
device regardless of whether drift was detected, since operators
also use it after manual Sources.xml edits or after running the
sources_xml_present quick fix.

Quick fix POSTs `<updates><sourcesUpdated/></updates>` to the
speaker's /notification endpoint. Manual command of equivalent
shape provided for cloud-deployed setups where the service
can't reach the speaker.

Recurring debug pattern from #175, disc #223, implied in #314.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 bb11b9d48c feat(health): add DNS interception sanity check
Queries this service's own DNS server for every intercepted
Bose hostname (api.bose.com, content.api.bose.io, etc.) and
verifies the answer is the configured service IP. Catches:

  - DNS subsystem disabled or unbound (speakers using us as
    their resolver get NXDOMAIN).
  - DNS running but answers point at a stale IP (operator
    changed the LAN address without restarting).
  - Subset of intercepts silently failing — emits the failing
    hostname list explicitly so it's obvious which patterns are
    falling through shouldIntercept.

For the mismatch case the finding includes a copyable
`nslookup … <our-dns-bind>` so operators can verify the same
behaviour from the speaker's network.

To avoid duplicating the intercept list, exports it as
`discovery.InterceptedBoseHosts` instead — same string slice
that DNSDiscovery.shouldIntercept walks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 7d46ae2280 feat(health): compare speaker /presets count with service Presets.xml
Probes http://<ip>:8090/presets for each device and counts the
returned <preset id=…> entries against the service-side
Presets.xml count. Three outcomes:

  - Match: no finding.
  - Speaker has 0 while service has entries: WARNING — the
    post-migration / post-reset preset-loss pattern from
    discussion #295 and #235.
  - Counts differ otherwise: INFO with both numbers in the
    message, so the operator can decide whether to sync.

Reachability / parse failures degrade to info-level findings
with a copyable curl command, matching the dual-mode pattern
the rest of the slice uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 77188418a7 feat(health): detect dead Bose orion URLs in service Presets.xml
Recurring failure mode in issues #218 and #224: presets saved
before the May 2026 cloud shutdown still carry
content.api.bose.io/.../orion URLs in their <location>, which
the speaker fetches directly post-migration. Result: playback
silently fails because the dead host can't serve the request
and the speaker has no fallback path.

Passive filesystem scan over every device's service-side
Presets.xml; emits a warning per device listing the affected
preset slot IDs and a copyable sed snippet that strips the dead
host prefix, leaving the BMX-relative /v1/playback/... path
that this service can resolve.

No probe, no LAN access needed — purely a service-side data
check, so it's also safe to run on cloud-deployed AfterTouch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 41f21f3761 feat(health): add per-device "play ding" affordance
For each known device, surface an info-level finding with a
"Play ding" quick fix and an equivalent curl command. The fix
POSTs an INTERNET_RADIO ContentItem to the speaker's /select
endpoint pointing at <serverURL>/media/aftertouch-ding.wav — the
asset committed earlier in this branch.

No external dependency (unlike TuneIn-based playback tests from
issues #94, #175, #188, #214, #218, #224, #235, #253, #262,
#272), so it works for cloud-deployed AfterTouch as long as the
speaker can reach the service URL.

Dual-mode by construction: the curl command in ManualCommands
is the same shape the server-side fix uses, so operators on
LAN-isolated setups can paste it and trigger the same playback
from a reachable host. Skipped (with an explanatory finding)
when SERVER_URL isn't configured — the speaker would have
nowhere to fetch the audio from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 b18272480a feat(health): probe HTTPS endpoint cert chain against system roots
Cloud-deploy reports (discussion #295 et al.) repeatedly came
down to "does the speaker trust AfterTouch's cert?". Add a check
that dials the configured HTTPS endpoint, attempts validation
against the system trust store, and:

  - Says nothing when the chain validates — typical for a public
    CA chain (Let's Encrypt, etc.) the speaker firmware trusts
    natively. No action needed.
  - Warns when validation fails and surfaces the chain context:
    subject, issuer, SANs, expiry, and the underlying error so
    operators can copy a diagnosis into a bug report. Includes a
    copyable suggestion — install-ca when the leaf looks
    self-signed (Subject == Issuer heuristic), or an
    `openssl s_client` invocation for unknown/foreign chains.

Reads the HTTPS URL via a closure on Server.GetSettings(), so
later restarts pick up new URLs without re-registration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 dee5a0146c feat(health): check speaker <margeURL> against configured hosts
For each device, probe /info and extract the <margeURL> the
speaker is configured to talk to. Compare the hostname against
the service's expected-hosts list (serverURL host +
httpsServerURL host + --tls-extra-host values).

When the speaker is pointed at a host AfterTouch doesn't claim,
emit a warning with two pieces of context:
  - the actual <margeURL>, so the operator sees the drift
  - a copyable `soundtouch-service --tls-extra-host=<host>`
    suggestion, which is the right fix when the speaker should
    keep talking to AfterTouch via the unexpected hostname (the
    other fix is re-migration, which is mentioned in the details).

Reachability / parse failures are intentionally silent here —
speaker_info_reachable already covers those, no need to double-warn.

Required plumbing: Server.SetExpectedHosts so main.go can pass
config.domains in, plus an ExpectedHosts() getter the closure-form
registration reads at run time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 255dd655b5 feat(health): compare speaker /sources with service Sources.xml
For each device, probe http://<ip>:8090/sources and compare the
set of source types against the service-side Sources.xml. The two
documents have *different* schemas (sourceItem attributes vs.
source elements with sourceKey children), so we compare the
extracted type sets rather than diffing XML directly.

Two finding shapes:
  - WARN: service advertises types the speaker doesn't have
    (e.g. TUNEIN, RADIO_BROWSER missing after a factory reset).
    Includes a copyable POST /notification command that triggers
    a sourcesUpdated refresh without a reboot.
  - INFO: speaker has types the service doesn't know about
    (mostly harmless — usually AUX or BLUETOOTH-style local-only
    sources). Surfaces it so operators notice managed sources
    that drifted out of the service config.

Recurring debug pattern from issues #175, #195, #214, #218, #236,
disc #315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 195403f42a feat(health): add speaker /info reachability check
For every known device, probe http://<ip>:8090/info from the
service and emit findings for:
  - Unreachable speakers — surfaces a copyable curl command the
    operator can run from a host on the speaker's LAN.
  - Speakers replying 200 but with empty <margeAccountUUID> —
    the TPDA pairing-state failure mode documented in
    discussion #223 ("Account ID = (empty)" in logread).
  - Non-200 HTTP responses and malformed /info bodies, both as
    warnings with the underlying detail in the finding.

Uses the ProbeGet helper from the previous commit; the dual-mode
fallback is the curl command emitted via ManualCommands when
server-side reach fails — appropriate when AfterTouch is hosted
off the speaker's LAN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 90a30f9fce feat(health): add ProbeGet helper and ManualCommands on findings
Diagnostic checks coming next need to talk to speakers on the
LAN, which the service can't always reach — e.g. AfterTouch
hosted publicly while the operator's browser sits on the speaker
subnet. Establish the dual-mode primitive first so subsequent
checks can use it consistently:

- ProbeGet(ctx, url, timeout) issues a short-timeout GET and
  always returns a CurlCommand the operator can run from a host
  that can reach the target, regardless of whether the
  server-side fetch succeeded.
- Finding gains an optional ManualCommands field; the admin UI
  renders each as a labelled, copyable code block with a Copy
  button and an optional hint line.

No new checks yet — that's the next commit. This one only adds
the primitive and the rendering path so each subsequent check is
a one-file diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c89a66b08a feat(media): add AfterTouch "ding" signature audio
A 600 ms two-chirp sound derived from the braille S+T pair that
makes up the AfterTouch logo. Used as the test-playback target so
operators can confirm a freshly migrated speaker actually emits
audio without depending on TuneIn or any external service.

Mapping: dot rows → pitches (A5/E5/A4), dot columns → stereo
channels. S (dots 2,3,4) renders first, then T (dots 2,3,4,5) —
audibly "S plus one more voice".

Generator under scripts/gen-aftertouch-ding regenerates the file
on demand:

  go run ./scripts/gen-aftertouch-ding \
    -o pkg/service/handlers/static/media/aftertouch-ding.wav

22050 Hz stereo 16-bit PCM, ~52 KB. Picked up by the existing
static/media/* embed in handlers_media.go, so it's served at
GET /media/aftertouch-ding.wav once handlers can play it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:20:40 +02:00
Tobias GesellchenandClaude Opus 4.7 c3723dc0e6 feat(service): add Logs tab streaming the live stderr trace
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.

The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.

- pkg/service/logbuf: io.Writer ring with \n splitting,
  partial-line buffering, monotonic Seq, Since(since, limit)
  reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
  capacity}. Polls at 1.5s while the tab is active; paused on
  document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
  (auto-disables when the user scrolls up), monospace dark view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:12:25 +02:00
Tobias GesellchenandClaude Opus 4.7 f791145976 feat(service): add Health tab with datastore checks and quick fixes
Discussion #295 surfaced that a paired device without Sources.xml
silently breaks playback — /full omits TUNEIN and selection fails
with 1005. initializeDefaultSources only runs at startup over
existing devices, so a device that checks in later is never
seeded.

Add a Health tab to the admin UI that runs registered checks
against the datastore and offers one-click remediations. The
first check flags missing Sources.xml per device; its quick fix
writes the canonical defaults via SaveConfiguredSources. The
check/fix registry is designed so adding Presets.xml,
Recents.xml, or future reachability probes is a one-file diff.

- New /setup/health (GET) and /setup/health/fix (POST) routes
- pkg/service/health: Registry, Check, Finding, QuickFix types
- Sources.xml-present check + create_default_sources fix
- "7. Health" tab in pkg/service/handlers/web/

Inspired by issue #327's MAINTENANCE tab proposal; curl/URL
helper content from that issue can slot into the same tab in
a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:00:16 +02:00
Tobias GesellchenandClaude Opus 4.7 882d0633fb fix(bmx): emit BMX-relative playback hrefs in TuneIn nav/search results
The b95bdae split changed BmxPlayback.Href to raw `Tune.ashx?id=…` URLs,
which the speaker's BMX module fetches directly — failing `IsItBose`,
sending no auth, and getting 401 from radiotime. Restore the v0.85.0
shape (`/v1/playback/{station|episodes}/{id}`) so playback flows back
through HandleTuneInPlayback. Also restore play-link emission for Topic
search results (single podcast episodes); `Tune.ashx?id=t<N>` accepts
them like station IDs, so the same path works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:24:34 +02:00
Tobias Gesellchen 68760a8977 feat(service): add --tls-extra-host for additional TLS cert SAN entries
The leaf cert generator already routes IP-shaped entries into the
IPAddresses SAN, and getDomains already feeds it the hostnames parsed
from --server-url and --https-server-url. Add an explicit
--tls-extra-host flag (repeatable, env TLS_EXTRA_HOST) for the
remaining cases: multi-homed hosts, reverse-proxy frontends, or
browsing the admin UI via a LAN IP that isn't part of the configured
server URLs.

Resolves the ERR_CERT_COMMON_NAME_INVALID Chrome refuses when the URL
bar hostname (e.g. the host's LAN IP) isn't in any cert SAN, even
when the local CA is trusted.
2026-05-18 23:09:28 +02:00
dependabot[bot] e1d009de04 ci(deps): bump codecov/codecov-action in the security-actions group
Bumps the security-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:40:55 +02:00
dependabot[bot] cee11b4799 ci(deps): bump github/codeql-action from 4.35.4 to 4.35.5
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.4 to 4.35.5.
- [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/68bde559dea0fdcac2102bfdf6230c5f70eb485e...9e0d7b8d25671d64c341c19c0152d693099fb5ba)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:40:36 +02:00
github-actions[bot] 4057e4b1a1 chore: sync static dependencies with package.json 2026-05-18 22:38:40 +02:00
dependabot[bot] 3331b1e93d deps(deps): bump preact from 10.26.1 to 10.29.2
Bumps [preact](https://github.com/preactjs/preact) from 10.26.1 to 10.29.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.26.1...10.29.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.29.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 22:38:40 +02:00
476 changed files with 34602 additions and 2777 deletions
+87
View File
@@ -0,0 +1,87 @@
Draft a "News & Updates" blog post for AfterTouch covering recent git activity, then open a draft PR for review.
## Step 1 — Determine lookback window
Run:
```
git log --format="%ad" --date=short -- docs/content/blog/ | grep -v '_index' | head -1
```
If a date is returned, use it as SINCE.
If the output is empty (no posts yet), compute SINCE = 30 days before today:
- macOS: `date -v-30d +%Y-%m-%d`
- Linux: `date -d '30 days ago' +%Y-%m-%d`
## Step 2 — Collect commits since SINCE
Run:
```
git log --format="%ad %h %s" --date=short --since="$SINCE" --no-merges
```
Exclude these (they are noise):
- Subjects matching: `^(ci|chore|deps|bump|Bump|test|lint|style|code style|debug)`
- Dependabot bumps (subject contains "bump" and includes a package name pattern)
- Routine doc link/URL fixes
Group the remaining commits into categories:
- **NEW FEATURES** — subjects starting with `feat(` or `feat:`
- **BUG FIXES** — subjects starting with `fix(` or `fix:`
- **SECURITY** — subjects starting with `sec` or containing "security", "inject", "path expression"
- **DOCS** — user-visible doc changes only (new guides, major restructures)
- **MAINTENANCE** — everything else that passed the filter
Omit empty categories entirely.
## Step 3 — Current version
Run: `git tag --sort=-version:refname | head -1`
## Step 4 — Determine the period label
Use the first and last commit dates from Step 2 to produce a human-readable label,
e.g. "May 2026" or "April May 2026".
## Step 5 — Write the blog post
Create the file at: `docs/content/blog/YYYY-MM-slug.md`
- YYYY-MM = today's year-month
- slug = short kebab-case summary of the biggest theme
Use this exact frontmatter shape:
```yaml
---
title: "AfterTouch PERIOD: <one-line theme>"
date: YYYY-MM-DD
description: "<one sentence, ≤200 chars, suitable as a standalone teaser>"
tags:
- <up to 4 tags from: security, tls, discovery, docs, cli, web, spotify, amazon, health, migration, fixes, ci>
sidebar:
exclude: true
---
```
Body structure:
1. Opening paragraph (35 sentences) explaining what happened and why it matters to someone running AfterTouch.
2. One `##` section per non-empty category. Use bullet points written for an operator audience — no raw git subjects, no internal Go package paths.
3. End with: `**Current release:** vX.Y.Z`
Target length: 300600 words. Never include real IPs, MAC addresses, account IDs, or device names.
## Step 6 — Create a branch and open a draft PR
```bash
git checkout -b blog/YYYY-MM-update
git add docs/content/blog/YYYY-MM-slug.md
git commit -m "docs(blog): add PERIOD update post"
git push -u origin blog/YYYY-MM-update
gh pr create --draft \
--title "Blog: PERIOD update post" \
--body "Automated draft from /blog-update skill. Review content before merging — deployment is automatic on merge to main."
```
If the `documentation` label exists on the repo, add `--label documentation`.
## Step 7 — Done
Report the PR URL. Do not merge, approve, or request review.
+12 -57
View File
@@ -1,74 +1,29 @@
# CodeQL configuration for enhanced security analysis
# See: https://docs.github.com/en/code-security/codeql-cli/using-the-codeql-cli/creating-codeql-query-suites
# CodeQL configuration
# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning
name: "Go Security Analysis"
disable-default-queries: false
queries:
# Include default security queries
- uses: security-extended
- uses: security-and-quality
# Additional Go-specific security queries
- name: go-security-extra
uses:
- go/bad-redirect-check
- go/clear-text-logging
- go/incorrect-integer-conversion
- go/log-injection
- go/missing-regexp-anchor
- go/path-injection
- go/request-forgery
- go/sensitive-package-import
- go/sql-injection
- go/uncontrolled-allocation-size
- go/unsafe-quoting
- go/useless-regexp-character-escape
- go/zip-slip
# Configure paths to exclude from analysis
paths-ignore:
- "**/*.pb.go" # Generated protobuf files
- "**/*_gen.go" # Generated code
- "**/vendor/**" # Vendor dependencies
- "**/build/**" # Build artifacts
- "**/scripts/**" # Build scripts
- "**/*_test.go" # Test files (optional - remove if you want to analyze tests)
# Configure paths to include (if not specified, all Go files are included)
# Paths to include
paths:
- "cmd/**/*.go"
- "pkg/**/*.go"
- "*.go"
# Query filters to reduce noise
# Paths to exclude from analysis
paths-ignore:
- "**/*.pb.go" # Generated protobuf files
- "**/*_gen.go" # Generated code
- "**/vendor/**" # Vendor dependencies
- "**/build/**" # Build artifacts
- "**/scripts/**" # Build scripts
- "**/*_test.go" # Test files
query-filters:
- exclude:
id: go/unused-variable
reason: "Can be noisy in development"
- exclude:
id: go/hardcoded-credentials
reason: "Will be handled by separate secret scanning"
# Configuration for specific query packs
packs:
# Use the official CodeQL Go queries
- codeql/go-queries
# Additional community query packs for enhanced security
- codeql/go-queries@~0.0.0 # Latest version
# Custom configuration for specific queries
query-config:
go/path-injection:
# Configure severity levels
severity: "error"
go/sql-injection:
severity: "error"
go/request-forgery:
severity: "warning"
go/log-injection:
severity: "warning"
go/clear-text-logging:
severity: "note"
+63
View File
@@ -38,6 +38,69 @@ updates:
patterns:
- "golang.org/*"
# Hugo module dependency updates (docs site)
- package-ecosystem: "gomod"
directory: "/docs"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "go"
- "docs"
rebase-strategy: "auto"
# Example module dependency updates
- package-ecosystem: "gomod"
directory: "/examples/navigation-station-demo"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "go"
rebase-strategy: "auto"
- package-ecosystem: "gomod"
directory: "/examples/preset-management"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 3
reviewers:
- "gesellix"
assignees:
- "gesellix"
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "go"
rebase-strategy: "auto"
# GitHub Actions workflow dependency updates
- package-ecosystem: "github-actions"
directory: "/"
+1 -1
View File
@@ -27,7 +27,7 @@
"pattern": "^https://pkg.go.dev.*badge"
},
{
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
"pattern": "^/images/"
},
{
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
+42 -30
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
@@ -53,7 +53,7 @@ jobs:
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
file: ./coverage.out
flags: unittests
@@ -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
@@ -77,7 +77,7 @@ jobs:
run: sudo apt-get install -y libpcap-dev
- name: Run golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1
with:
version: latest
args: --timeout=5m
@@ -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: |
@@ -215,8 +217,8 @@ jobs:
)
for img in "${IMAGES[@]}"; do
if [ ! -f "docs/images/$img" ]; then
echo "::warning file=docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/images/"
if [ ! -f "docs/static/images/$img" ]; then
echo "::warning file=docs/content/docs/guides/MIGRATION-GUIDE.md::Pending image '$img' is missing from docs/static/images/"
fi
done
@@ -226,7 +228,7 @@ jobs:
echo "Validating API documentation consistency..."
# Check API cookbook
if [ -f "docs/reference/API-COOKBOOK.md" ]; then
if [ -f "docs/content/docs/reference/API-COOKBOOK.md" ]; then
echo "✓ API Cookbook exists"
else
echo "✗ API Cookbook missing"
@@ -234,7 +236,7 @@ jobs:
fi
# Check getting started guide
if [ -f "docs/guides/GETTING-STARTED.md" ]; then
if [ -f "docs/content/docs/guides/GETTING-STARTED.md" ]; then
echo "✓ Getting Started guide exists"
else
echo "✗ Getting Started guide missing"
@@ -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,10 +305,14 @@ 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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Set build date
id: build_date
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
- name: Determine push eligibility
id: push-check
@@ -324,7 +330,7 @@ jobs:
- name: Log in to GitHub Container Registry
if: steps.push-check.outputs.should-push == 'true'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -332,7 +338,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -342,7 +348,7 @@ jobs:
type=ref,event=branch,prefix=preview-branch-,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-service
@@ -350,29 +356,35 @@ jobs:
push: ${{ steps.push-check.outputs.should-push == 'true' }}
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.outputs.labels }}
build-args: |
COMMIT=${{ github.sha }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for soundtouch-web
id: meta-web
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
- 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
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
- 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 }}
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -380,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 }}
@@ -404,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"
+56
View File
@@ -0,0 +1,56 @@
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '36 6 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
packages: read
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: manual
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
- name: Checkout repository
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@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: ${{ matrix.language == 'go' && './.github/codeql-config.yml' || '' }}
- name: Build Go (required for manual build-mode)
if: matrix.language == 'go'
run: go build ./...
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:${{ matrix.language }}"
+11 -5
View File
@@ -20,14 +20,20 @@ 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
- name: Build with Jekyll
uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
- name: Setup Hugo
uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1
with:
source: 'docs/'
destination: '_site'
hugo-version: 'latest'
extended: true
- name: Build with Hugo
run: hugo --source docs/ --minify --destination ../_site --baseURL "${{ steps.pages.outputs.base_url }}"
env:
HUGO_ENVIRONMENT: production
HUGO_PARAMS_GITHASH: ${{ github.sha }}
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
+72 -20
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,13 +534,17 @@ 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
run: echo "date=$(date -u +%Y-%m-%d)" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -535,7 +552,7 @@ jobs:
- name: Extract metadata (tags, labels) for soundtouch-service
id: meta-service
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}
tags: |
@@ -544,7 +561,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-service Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-service
@@ -552,12 +569,43 @@ jobs:
push: true
tags: ${{ steps.meta-service.outputs.tags }}
labels: ${{ steps.meta-service.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
- 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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository }}-web
tags: |
@@ -566,7 +614,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push soundtouch-web Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
target: soundtouch-web
@@ -574,6 +622,10 @@ jobs:
push: true
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
build-args: |
VERSION=v${{ needs.validate.outputs.version }}
COMMIT=${{ github.sha }}
DATE=${{ steps.build_date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
+8 -60
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
@@ -29,10 +29,8 @@ jobs:
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Install security scanning tools
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
go install github.com/sonatypecommunity/nancy@latest
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck (Official Go vulnerability scanner)
run: |
@@ -40,21 +38,6 @@ jobs:
govulncheck ./...
echo "::endgroup::"
- name: Run Nancy vulnerability scanner
run: |
echo "::group::Running Nancy dependency scanner"
go list -json -deps ./... | nancy sleuth
echo "::endgroup::"
- name: Upload vulnerability scan results
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vulnerability-scan-results
path: |
vulnerability-report.json
nancy-report.json
static-analysis:
name: Static Security Analysis
runs-on: ubuntu-latest
@@ -63,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
@@ -95,40 +78,11 @@ jobs:
- name: Upload Semgrep SARIF results
if: always()
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
sarif_file: semgrep.sarif
continue-on-error: true
codeql-analysis:
name: CodeQL Analysis
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install libpcap
run: sudo apt-get install -y libpcap-dev
- name: Initialize CodeQL
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: go
config-file: ./.github/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:go"
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
@@ -138,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
@@ -150,7 +104,7 @@ jobs:
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [vulnerability-scan, static-analysis, codeql-analysis]
needs: [vulnerability-scan, static-analysis]
if: always()
permissions:
contents: read
@@ -173,17 +127,11 @@ jobs:
echo "❌ **Static Analysis**: FAILED" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.codeql-analysis.result }}" == "success" ]]; then
echo "✅ **CodeQL Analysis**: PASSED" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **CodeQL Analysis**: FAILED" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "For detailed results, check the individual job logs above." >> $GITHUB_STEP_SUMMARY
- name: Fail on security issues
if: needs.vulnerability-scan.result == 'failure' || needs.static-analysis.result == 'failure' || needs.codeql-analysis.result == 'failure'
if: needs.vulnerability-scan.result == 'failure' || needs.static-analysis.result == 'failure'
run: |
echo "Security scan detected issues. Please review the results above."
exit 1
+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 }}
+21 -1
View File
@@ -15,6 +15,7 @@ dist/
/soundtouch-backup
/soundtouch-cli
/soundtouch-service
/soundtouch-player
/soundtouch-web
/dummy-speaker
/example-mdns
@@ -48,7 +49,8 @@ node_modules/
# IDE and editor files
.vscode/
.idea/
.claude/
.claude/*
!.claude/commands/
.junie/
*.swp
*.swo
@@ -114,3 +116,21 @@ stockholm_zip/*.zip
# resolved items (DONE). Both are session-local scratch, not project docs.
NEXT.md
DONE.md
# Code-scanning working notes — snapshot + remediation plan; not committed
# until the sweep is complete and the notes are stable.
CODE-SCANNING-NOTES.md
# Plan/tracking note for the Health-tab debug-utility programme.
# Living document; commit history of the checks themselves is the
# source of truth for what shipped.
SERVICE-HEALTH.md
# Diagnostic encryption keys — private key stays local with the maintainer
keys/private/
# Hugo (docs site)
# Hugo build artifacts (docs site)
docs/.hugo_build.lock
docs/public/
docs/resources/
+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"
+18 -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/
@@ -182,6 +182,12 @@ unless the user has already authorised that specific action in this
session. Prefer reversible alternatives (`git stash` over
`git reset --hard`).
**Force-flags also require explicit approval.** `git add -f` (force-add
a gitignored file), `git push --force`, `git push --force-with-lease`,
and any other flag that overrides a git safety mechanism must be
proposed and confirmed before running, for the same reason: they
bypass protections that exist intentionally.
## What never goes into this repo
This repository is public. The following must never be committed:
@@ -221,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
+3 -3
View File
@@ -88,7 +88,7 @@ When filing a bug report, include:
Feature requests are welcome! Please:
1. **Check if the feature already exists** in documentation
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/reference/API-ENDPOINTS.md))
2. **Verify it's supported by the SoundTouch API** (see [official API docs](docs/content/docs/reference/API-ENDPOINTS.md))
3. **Explain the use case** and how it benefits users
### 🔧 Contributing Code
@@ -489,8 +489,8 @@ Sponsorship is entirely optional. Code, docs, and bug reports remain the most us
- [Go Documentation](https://golang.org/doc/)
- [Effective Go](https://golang.org/doc/effective_go.html)
- [Bose SoundTouch API Documentation](docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/PROJECT-PATTERNS.md)
- [Bose SoundTouch API Documentation](docs/content/docs/reference/API-ENDPOINTS.md)
- [Project Architecture](docs/content/docs/appendix/PROJECT-PATTERNS.md)
- [Development Status](docs/archive/STATUS.md)
---
+72 -9
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
@@ -8,6 +8,12 @@ ARG TARGETARCH
ARG TARGETOS
ARG TARGETVARIANT
# Version info injected at build time; defaults keep local builds working.
# The release workflow passes VERSION, COMMIT, and DATE via --build-arg.
ARG VERSION=dev
ARG COMMIT=unknown
ARG DATE=unknown
WORKDIR /app
# Copy go mod and sum files
@@ -19,16 +25,24 @@ COPY . .
# Build the soundtouch-service
RUN if [ "${TARGETARCH}" = "arm" ] && [ -n "${TARGETVARIANT}" ]; then \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} go build -o /soundtouch-service ./cmd/soundtouch-service; \
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-service ./cmd/soundtouch-service; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-service ./cmd/soundtouch-service; \
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-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 -o /soundtouch-web ./cmd/soundtouch-web; \
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-player ./cmd/soundtouch-player; \
else \
CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /soundtouch-web ./cmd/soundtouch-web; \
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-player ./cmd/soundtouch-player; \
fi
# soundtouch-service image
@@ -36,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
@@ -43,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"]
+77 -28
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps
.PHONY: all build build-cli test test-coverage test-http-client test-http-client-rotate check fmt vet lint clean dev help screenshots build-stockholm-image prepare-stockholm update-static-deps dev-docs dev-docs-tidy hugo
# Load .env if present (simple KEY=VALUE format, no shell quoting)
-include .env
@@ -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/
@@ -454,6 +484,22 @@ screenshots:
@echo "Capturing documentation screenshots..."
@bash scripts/screenshots/run.sh
# Documentation site (Hugo + Hextra via Docker)
# First run: make dev-docs-tidy (downloads Hextra, writes docs/go.sum)
# Then: make dev-docs (http://localhost:1313, live reload)
dev-docs:
HUGO_PARAMS_GITHASH=$(shell git rev-parse HEAD) docker compose -f docker-compose.docs.yml up
dev-docs-tidy:
docker compose -f docker-compose.docs.yml run --rm hugo mod tidy --source docs/
# Run any hugo CLI command inside the docs container:
# make hugo ARGS="version"
# make hugo ARGS="new content/docs/guides/my-guide.md"
ARGS ?=
hugo:
docker compose -f docker-compose.docs.yml run --rm hugo --source docs/ $(ARGS)
help:
@echo "Available targets:"
@echo " build - Build the CLI tool, service, and examples"
@@ -478,6 +524,9 @@ help:
@echo " dev-service-proxy - Build and run service with proxy (PROXY_URL=url required)"
@echo " dev-service-stockholm - Build and run service with Stockholm frontend (requires prior 'make prepare-stockholm')"
@echo " screenshots - Capture documentation screenshots (headless Chrome via chromedp)"
@echo " dev-docs - Serve documentation site locally via Docker (http://localhost:1313)"
@echo " dev-docs-tidy - Run hugo mod tidy (first run, or after hugo.toml module changes)"
@echo " hugo ARGS=... - Run any hugo CLI command via Docker (e.g. make hugo ARGS=version)"
@echo " dev-discover - Build and run device discovery"
@echo " dev-info - Build and get device info (HOST=ip required)"
@echo " dev-mdns - Build and run mDNS discovery example"
@@ -492,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"
@@ -519,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"
+26 -24
View File
@@ -9,11 +9,13 @@
> by, or otherwise connected to Bose Corporation.** See
> [Disclaimer](#disclaimer) for the full statement.
## Context: Cloud Shutdown
## The Bose Cloud Has Shut Down
Bose is shutting down SoundTouch cloud services on **May 6, 2026**. After that, music service browsing, preset sync, and the official SoundTouch app stop working. This toolkit lets you keep your speakers fully functional.
Bose shut down SoundTouch cloud services on **May 6, 2026**. Presets, music service browsing, and stereo pairing no longer work through Bose's infrastructure. AfterTouch restores all of these — no Bose infrastructure required.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html) for the full picture.
See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/) for the full picture.
[![AfterTouch docs homepage](media/docs-homepage.png)](https://gesellix.github.io/Bose-SoundTouch/)
---
@@ -23,15 +25,13 @@ See the [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVI
A local server that replaces the Bose cloud ("AfterTouch"). Once your speaker is redirected to it, you have full control without any Bose cloud dependency. The built-in web UI at `http://localhost:8000` handles all setup — no config files needed to get started.
If you don't want to run a server for this - no problem. The service is small enough to run on the SoundTouch itself. See the [On-Device Installer](./scripts/on-device-install/README.md) for instructions.
Not sure which approach fits your situation? See the [Deployment Overview](./docs/content/docs/guides/DEPLOYMENT-OVERVIEW.md) — it compares running AfterTouch on a Raspberry Pi or other always-on host against running it directly on the SoundTouch speaker, with links to step-by-step walkthroughs for each path.
**Two scenarios:**
**Getting started:**
**Before shutdown — migrate your existing setup**
While the Bose cloud is still running, use `soundtouch-backup` to save your account data. The local service web UI then helps with the migration so your speaker keeps its presets and credentials.
**Already migrated before May 6** — your presets and credentials are preserved. AfterTouch picks up where the Bose cloud left off.
**After shutdown or factory reset — start fresh**
Create a local account, configure your speakers, and start using them immediately. No Bose infrastructure required.
**Starting fresh (or after a factory reset)** — create a local account, configure your speakers, and start using them immediately.
**Redirecting your speaker**
@@ -50,7 +50,7 @@ The web UI walks you through each method. DNS redirect requires HTTPS — the se
Some setup steps require SSH access to the speaker. Enable it once per device: create a file named `remote_services` on a FAT-formatted USB drive (the drive may need its bootable flag set — see [SoundCork issue #172](https://github.com/deborahgu/soundcork/issues/172)), and insert it while the speaker is powered on. After reboot, root SSH is available with no password.
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html) for step-by-step instructions.
See [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/DEVICE-INITIAL-SETUP/) and [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/) for step-by-step instructions.
---
@@ -66,15 +66,17 @@ See the [soundtouch-backup README](cmd/soundtouch-backup/README.md) for usage.
Command-line control of any SoundTouch device: play/pause/volume, presets, source selection, multiroom zones, device discovery, and more. Works entirely over the local network — no cloud dependency. Well-suited for scripting and home automation.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html) for full usage.
See the [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/) for full usage.
---
### 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.
---
@@ -86,21 +88,21 @@ See the [soundtouch-web README](cmd/soundtouch-web/README.md) for usage.
go get github.com/gesellix/bose-soundtouch
```
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
See the [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/) and [pkg.go.dev](https://pkg.go.dev/github.com/gesellix/bose-soundtouch) for documentation.
---
## Documentation
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/guides/GETTING-STARTED.html)
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SURVIVAL-GUIDE.html)
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-GUIDE.html)
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/guides/DEVICE-INITIAL-SETUP.html)
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/guides/MIGRATION-SAFETY.html)
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/guides/CLI-REFERENCE.html)
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/guides/SOUNDTOUCH-SERVICE.html)
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/guides/HTTPS-SETUP.html)
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/reference/API-ENDPOINTS.html)
- [Getting Started](https://gesellix.github.io/Bose-SoundTouch/docs/guides/GETTING-STARTED/)
- [Survival Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SURVIVAL-GUIDE/)
- [Migration Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-GUIDE/)
- [Device Initial Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/DEVICE-INITIAL-SETUP/)
- [Migration & Safety Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/MIGRATION-SAFETY/)
- [CLI Reference](https://gesellix.github.io/Bose-SoundTouch/docs/guides/CLI-REFERENCE/)
- [SoundTouch Service Guide](https://gesellix.github.io/Bose-SoundTouch/docs/guides/SOUNDTOUCH-SERVICE/)
- [HTTPS & CA Setup](https://gesellix.github.io/Bose-SoundTouch/docs/guides/HTTPS-SETUP/)
- [API Reference](https://gesellix.github.io/Bose-SoundTouch/docs/reference/API-ENDPOINTS/)
---
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+3 -3
View File
@@ -43,10 +43,10 @@ func main() {
log.Fatalf("start fake speaker: %v", err)
}
log.Printf("fake speaker HTTP listening on http://%s", s.HTTPAddr())
log.Printf("fake speaker HTTP listening on http://%s", sanitizeLog(s.HTTPAddr()))
if addr := s.TelnetAddr(); addr != "" {
log.Printf("fake speaker telnet listening on tcp://%s", addr)
log.Printf("fake speaker telnet listening on tcp://%s", sanitizeLog(addr))
}
if *register != "" {
@@ -58,7 +58,7 @@ func main() {
if err := registerWithService(*register, target); err != nil {
log.Printf("self-register failed: %v (continuing anyway)", err)
} else {
log.Printf("registered %s with service at %s", target, *register)
log.Printf("registered %s with service at %s", sanitizeLog(target), sanitizeLog(*register))
}
}
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+2 -2
View File
@@ -116,7 +116,7 @@ func main() {
defer close(entries)
if *verbose {
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", *service, *timeout)
log.Printf("mDNS: Starting scan for service '%s' with timeout %v", sanitizeLog(*service), *timeout)
}
// Query for services
@@ -196,7 +196,7 @@ func parseServiceEntry(entry *mdns.ServiceEntry, verbose bool) *ServiceInfo {
if verbose {
log.Printf("mDNS: Received service entry: Name='%s', Host='%s', Port=%d, AddrV4=%v, AddrV6=%v",
entry.Name, entry.Host, entry.Port, entry.AddrV4, entry.AddrV6)
sanitizeLog(entry.Name), sanitizeLog(entry.Host), entry.Port, entry.AddrV4, entry.AddrV6)
}
service := &ServiceInfo{
+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)
}
}
+1 -1
View File
@@ -207,6 +207,6 @@ Running `cloud` and `local` separately produces two archives. To combine them, u
## See also
- [Cloud Shutdown Survival Guide](../../docs/guides/SURVIVAL-GUIDE.md) — full migration context
- [Cloud Shutdown Survival Guide](../../docs/content/docs/guides/SURVIVAL-GUIDE.md) — full migration context
- [`soundtouch-cli`](../soundtouch-cli/) — live device control
- [`soundtouch-service`](../soundtouch-service/) — local cloud replacement
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"fmt"
"io"
"net/http"
"strings"
"github.com/urfave/cli/v2"
)
// cloudCommand assembles the `soundtouch-cli cloud …` command group.
// All subcommands talk to the AfterTouch service (not the speaker directly)
// and require --service-url.
func cloudCommand() *cli.Command {
return &cli.Command{
Name: "cloud",
Usage: "Manage AfterTouch service data (sources, accounts, devices)",
Subcommands: []*cli.Command{
cloudSourceCmd(),
},
}
}
func cloudSourceCmd() *cli.Command {
return &cli.Command{
Name: "source",
Usage: "Manage sources stored in AfterTouch",
Subcommands: []*cli.Command{
cloudSourceRemoveCmd(),
},
}
}
func cloudSourceRemoveCmd() *cli.Command {
return &cli.Command{
Name: "remove",
Usage: "Remove a source from AfterTouch's datastore for a specific device",
Flags: append(CloudCommonFlags,
&cli.StringFlag{
Name: "account",
Aliases: []string{"a"},
Usage: "Account ID",
Required: true,
},
&cli.StringFlag{
Name: "device",
Aliases: []string{"d"},
Usage: "Device ID",
Required: true,
},
&cli.StringFlag{
Name: "id",
Usage: "Source ID to remove (e.g. 10002)",
},
&cli.StringFlag{
Name: "type",
Aliases: []string{"t"},
Usage: "Source type to remove (e.g. INTERNET_RADIO). Resolved to a canonical ID; fails if multiple sources share the type.",
},
),
Action: cloudSourceRemove,
}
}
// canonicalSourceID maps well-known SourceKeyType values to their canonical IDs.
// Used to resolve --type to an ID without requiring a round-trip GET.
// TODO We need to ensure that ids here are consistent with the ones used in the AfterTouch service.
var canonicalSourceID = map[string]string{
"AUX": "10001",
"INTERNET_RADIO": "10002",
"LOCAL_INTERNET_RADIO": "10003",
"TUNEIN": "10004",
"RADIO_BROWSER": "10005",
}
func cloudSourceRemove(c *cli.Context) error {
serviceURL := strings.TrimRight(c.String("service-url"), "/")
account := c.String("account")
device := c.String("device")
sourceID := c.String("id")
sourceType := strings.ToUpper(c.String("type"))
if sourceID == "" && sourceType == "" {
return fmt.Errorf("one of --id or --type is required")
}
if sourceID != "" && sourceType != "" {
return fmt.Errorf("only one of --id or --type may be given")
}
if sourceType != "" {
id, ok := canonicalSourceID[sourceType]
if !ok {
return fmt.Errorf("unknown source type %q; use --id for non-canonical sources", sourceType)
}
sourceID = id
}
url := fmt.Sprintf("%s/setup/sources/%s/%s/%s", serviceURL, account, device, sourceID)
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent {
PrintSuccess(fmt.Sprintf("Removed source %s from device %s (account %s)", sourceID, device, account))
if sourceType != "" {
fmt.Printf(" Type: %s\n", sourceType)
}
return nil
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("service returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
+5
View File
@@ -15,6 +15,11 @@ import (
func discoverDevices(c *cli.Context) error {
fmt.Printf("Discovering SoundTouch devices...\n")
// CLI discovery is interactive — flip on verbose protocol logging
// so operators can see per-packet / per-header detail. The service
// binary leaves this off so its log stays terse.
discovery.SetVerbose(c.Bool("verbose"))
// Load configuration
cfg, err := config.LoadFromEnv()
if err != nil {
+1 -1
View File
@@ -568,7 +568,7 @@ type VerboseLogger struct{}
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
timestamp := time.Now().Format("15:04:05")
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
}
type SilentLogger struct{}
+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 (
+223 -38
View File
@@ -48,6 +48,7 @@ func setupCommand() *cli.Command {
setupWaitAPCmd(),
setupWaitOnlineCmd(),
setupSSHCheckCmd(),
setupRemoteServicesCmd(),
setupInstallCACmd(),
setupMigrateCmd(),
setupRebootCmd(),
@@ -536,6 +537,51 @@ func setupSSHCheckCmd() *cli.Command {
}
}
func setupRemoteServicesCmd() *cli.Command {
return &cli.Command{
Name: "remote-services",
Usage: "Enable (default) or disable the remote_services SSH-enablement marker on the speaker",
Before: RequireHost,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "remove",
Usage: "Remove all remote_services marker files (disables SSH after next reboot)",
},
},
Action: func(c *cli.Context) error {
cfg := GetClientConfig(c)
m := setup.NewManager("", nil, nil)
var (
logs string
err error
)
if c.Bool("remove") {
logs, err = m.RemoveRemoteServices(cfg.Host)
} else {
logs, err = m.EnsureRemoteServices(cfg.Host)
}
if logs != "" {
fmt.Print(logs)
}
if err != nil {
PrintError(err.Error())
return err
}
if c.Bool("remove") {
PrintSuccess("remote_services removed — SSH will no longer be enabled after next reboot")
} else {
PrintSuccess("remote_services enabled at a persistent location")
}
return nil
},
}
}
func setupInstallCACmd() *cli.Command {
return &cli.Command{
Name: "install-ca",
@@ -549,13 +595,18 @@ func setupInstallCACmd() *cli.Command {
cfg := GetClientConfig(c)
serviceURL := strings.TrimRight(c.String("service-url"), "/")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
certPEM, err := fetchCACert(serviceURL, c.String("auth"))
if err != nil {
PrintError(err.Error())
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)
@@ -576,11 +627,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)
@@ -698,11 +749,17 @@ func setupMigrateCmd() *cli.Command {
method := setup.MigrationMethod(c.String("method"))
serviceURL := c.String("service-url")
// For DNS-redirect methods, prove AfterTouch's DNS listener
// is alive by sending it a real query — that's the truth,
// regardless of what its settings claim.
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
m := setup.NewManager(serviceURL, nil, nil)
// For DNS-redirect methods check that AfterTouch's DNS listener
// is reachable — both from this machine and from the speaker.
if !c.Bool("skip-preflight") && (method == setup.MigrationMethodResolvConf || method == setup.MigrationMethodHosts) {
if err := requireAfterTouchDNSReachable(serviceURL); err != nil {
if err := runDNSPreflight(cfg.Host, serviceURL, m.NewSSH); err != nil {
PrintError(err.Error())
return err
}
@@ -720,8 +777,6 @@ func setupMigrateCmd() *cli.Command {
fmt.Printf("Migrating %s → %s using method=%s\n", cfg.Host, serviceURL, method)
m := setup.NewManager(serviceURL, nil, nil)
logs, err := m.MigrateSpeaker(cfg.Host, serviceURL, c.String("proxy-url"), nil, method)
if logs != "" {
fmt.Print(logs)
@@ -762,31 +817,53 @@ func preInstallCAForCLI(deviceIP, serviceURL string) error {
return nil
}
// requireAfterTouchDNSReachable sends a real DNS query to AfterTouch's
// port-53 listener and confirms it responds. This is the ground-truth
// preflight for DNS-redirect migration methods — config inspection (the
// previous approach via GET /setup/settings) can lag the actual listener
// state and can't tell us whether queries succeed end-to-end.
//
// We query a known-intercepted hostname (streaming.bose.com). Any IP in
// the response proves AfterTouch's DNS is alive on :53; if the listener
// is down the custom Dial just times out and the user gets a clear error.
func requireAfterTouchDNSReachable(serviceURL string) error {
// validateServiceURL returns an error if serviceURL cannot be parsed or has no
// hostname. A common mistake is a single-slash scheme (https:/host instead of
// https://host); the error message hints at the correction in that case.
func validateServiceURL(serviceURL string) error {
parsed, err := url.Parse(serviceURL)
if err != nil {
return fmt.Errorf("preflight: parse service URL %q: %w", serviceURL, err)
return fmt.Errorf("invalid --service-url %q: %w", serviceURL, err)
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("preflight: service URL %q has no hostname", serviceURL)
if parsed.Hostname() == "" {
hint := ""
if parsed.Scheme != "" && parsed.Opaque != "" {
hint = fmt.Sprintf(" (did you mean %s://%s?)", parsed.Scheme, strings.TrimPrefix(parsed.Opaque, "/"))
}
return fmt.Errorf("invalid --service-url %q: no hostname found%s", serviceURL, hint)
}
return nil
}
// dnsCheckResult holds the outcome of one DNS reachability probe.
type dnsCheckResult struct {
ok bool
unknown bool // SSH unavailable or nslookup not present — result indeterminate
detail string // "works" on success, error reason otherwise
}
func (r dnsCheckResult) label() string {
switch {
case r.ok:
return "✓ works"
case r.unknown:
return "? " + r.detail
default:
return "✗ " + r.detail
}
}
// cliDNSCheck sends a real DNS query for streaming.bose.com through the
// AfterTouch DNS listener to verify it is alive from this machine.
func cliDNSCheck(dnsHost string) dnsCheckResult {
resolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, _, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 3 * time.Second}
return d.DialContext(ctx, "udp", net.JoinHostPort(host, "53"))
return d.DialContext(ctx, "udp", net.JoinHostPort(dnsHost, "53"))
},
}
@@ -795,14 +872,91 @@ func requireAfterTouchDNSReachable(serviceURL string) error {
ips, err := resolver.LookupHost(ctx, "streaming.bose.com")
if err != nil {
return fmt.Errorf(
"preflight: DNS query to %s:53 failed: %w. AfterTouch's DNS listener is unreachable or not bound to port 53. Use --skip-preflight to bypass once you've verified DNS some other way",
host, err,
)
return dnsCheckResult{detail: err.Error()}
}
if len(ips) == 0 {
return fmt.Errorf("preflight: %s:53 returned no answers for streaming.bose.com — listener may be misconfigured", host)
return dnsCheckResult{detail: "no answers for streaming.bose.com — listener may be misconfigured"}
}
return dnsCheckResult{ok: true, detail: "works"}
}
// speakerDNSCheck SSHes into the speaker and runs nslookup streaming.bose.com
// against the AfterTouch DNS server to verify reachability from the device.
func speakerDNSCheck(deviceIP, dnsHost string, newSSH func(string) setup.SSHClient) dnsCheckResult {
addrs, err := net.LookupHost(dnsHost)
if err != nil || len(addrs) == 0 {
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("cannot resolve %s locally to run speaker-side check", dnsHost)}
}
dnsIP := addrs[0]
client := newSSH(deviceIP)
out, sshErr := client.Run(fmt.Sprintf("nslookup streaming.bose.com %s", dnsIP))
if sshErr != nil {
if strings.Contains(out, "not found") || strings.Contains(out, "No such file") {
return dnsCheckResult{unknown: true, detail: "nslookup not available on speaker"}
}
if strings.Contains(sshErr.Error(), "dial") || strings.Contains(sshErr.Error(), "connect") {
return dnsCheckResult{unknown: true, detail: fmt.Sprintf("SSH unavailable: %s", sshErr)}
}
msg := strings.TrimSpace(out)
if msg == "" {
msg = sshErr.Error()
}
return dnsCheckResult{detail: msg}
}
return dnsCheckResult{ok: true, detail: "works"}
}
// runDNSPreflight checks AfterTouch DNS reachability from both the CLI machine
// and the speaker, prints a table, and returns an error only when the speaker
// side definitively cannot reach the DNS listener (CLI-only failures are
// informational — the speaker's perspective is authoritative).
func runDNSPreflight(deviceIP, serviceURL string, newSSH func(string) setup.SSHClient) error {
parsed, _ := url.Parse(serviceURL)
dnsHost := parsed.Hostname()
type result struct {
cli dnsCheckResult
speaker dnsCheckResult
}
ch := make(chan result, 1)
go func() {
cliCh := make(chan dnsCheckResult, 1)
speakerCh := make(chan dnsCheckResult, 1)
go func() { cliCh <- cliDNSCheck(dnsHost) }()
go func() { speakerCh <- speakerDNSCheck(deviceIP, dnsHost, newSSH) }()
ch <- result{cli: <-cliCh, speaker: <-speakerCh}
}()
r := <-ch
if r.cli.ok && r.speaker.ok {
fmt.Printf("DNS preflight (%s:53) ✓ works\n", dnsHost)
return nil
}
fmt.Printf("DNS preflight (%s:53)\n", dnsHost)
fmt.Printf(" CLI host %s\n", r.cli.label())
fmt.Printf(" Speaker %s\n", r.speaker.label())
fmt.Println()
if !r.speaker.ok && !r.speaker.unknown {
return fmt.Errorf("AfterTouch DNS unreachable from speaker — %s migration would fail", serviceURL)
}
if r.speaker.unknown && !r.cli.ok {
return fmt.Errorf("cannot confirm DNS reachability (SSH unavailable from speaker, CLI probe also failed) — use --skip-preflight to bypass")
}
return nil
@@ -822,6 +976,11 @@ func setupVerifyCmd() *cli.Command {
cfg := GetClientConfig(c)
serviceURL := c.String("service-url")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
m := setup.NewManager(serviceURL, nil, nil)
summary, err := m.GetMigrationSummary(cfg.Host, serviceURL, c.String("proxy-url"), nil)
@@ -1013,6 +1172,11 @@ func setupPlanCmd() *cli.Command {
wifiSSID := c.String("wifi-ssid")
includePair := c.Bool("include-pair")
if err := validateServiceURL(serviceURL); err != nil {
PrintError(err.Error())
return err
}
m := setup.NewManager(serviceURL, nil, nil)
fmt.Printf("Probing %s …\n\n", cfg.Host)
@@ -1036,7 +1200,7 @@ func setupPlanCmd() *cli.Command {
renderPreResetNote()
}
renderPlanSteps(steps)
renderPlanSteps(steps, includePair)
return nil
},
@@ -1093,6 +1257,10 @@ func renderPlanState(deviceIP string, inspect *setup.InspectReport, summary *set
check(summary.IsPaired), check(summary.IsMigrated),
yesNo(summary.TelnetMigrated), yesNo(summary.XMLMigrated),
yesNo(summary.HostsMigrated), yesNo(summary.ResolvMigrated))
if summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
fmt.Println(" [⚠] remote_services enabled but not persistent (will be lost on reboot)")
}
}
func firmwareOf(info *setup.DeviceInfoXML) string {
@@ -1135,7 +1303,19 @@ func buildPlanSteps(
host = "<NEW_IP>" // subsequent commands target the discovered IP
}
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) {
// Persist remote_services before anything else when it's only in /tmp.
// SSH is reachable now, but the marker would be lost on the next reboot —
// which could happen mid-migration if power is cut or the reboot step runs
// before persistence is confirmed.
if !reset && summary != nil && summary.RemoteServicesEnabled && !summary.RemoteServicesPersistent {
steps = append(steps, planStep{
title: "Persist remote_services so SSH survives a reboot",
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup remote-services", host),
reason: "Marker is currently in /tmp only — lost on next reboot, which would break SSH mid-migration.",
})
}
if !reset && summary != nil && summary.IsMigrated && (!includePair || summary.IsPaired) && len(steps) == 0 {
return steps
}
@@ -1146,7 +1326,7 @@ func buildPlanSteps(
if includePair && (reset || (summary != nil && !summary.IsPaired)) {
steps = append(steps, planStep{
title: "Pair the device with an AfterTouch account",
cmd: fmt.Sprintf("soundtouch-cli setup pair --host=%s --service-url=%s", host, serviceURL),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup pair --service-url=%s", host, serviceURL),
reason: "Required for preset persistence, streaming services, multi-room zones.",
})
}
@@ -1178,7 +1358,7 @@ func resetSteps(host, wifiSSID string, inspect *setup.InspectReport) []planStep
return []planStep{
{
title: "Factory-reset the speaker",
cmd: fmt.Sprintf("soundtouch-cli setup factory-reset --host=%s", host),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup factory-reset", host),
reason: "Wipes account pairing, presets, Wi-Fi — gives a clean baseline for the SETUP state machine.",
},
{
@@ -1236,20 +1416,20 @@ func migrationSteps(host, serviceURL string, summary *setup.MigrationSummary, re
if dnsRedirect && summary != nil && !summary.CACertTrusted {
steps = append(steps, planStep{
title: "Install AfterTouch's CA cert on the speaker",
cmd: fmt.Sprintf("soundtouch-cli setup install-ca --host=%s --service-url=%s", host, serviceURL),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup install-ca --service-url=%s", host, serviceURL),
reason: "DNS-redirect methods keep using https://*.bose.com URLs — the device needs to trust AfterTouch's cert.",
})
}
steps = append(steps, planStep{
title: fmt.Sprintf("Apply URL migration using method=%s", method),
cmd: fmt.Sprintf("soundtouch-cli setup migrate --host=%s --service-url=%s --method=%s", host, serviceURL, method),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup migrate --service-url=%s --method=%s", host, serviceURL, method),
reason: methodReason,
})
steps = append(steps, planStep{
title: "Reboot the speaker",
cmd: fmt.Sprintf("soundtouch-cli setup reboot --host=%s", host),
cmd: fmt.Sprintf("soundtouch-cli --host=%s setup reboot", host),
reason: "The envswitch parallel-persistence layer only fully wins on next boot; reboot now to lock the new URLs in before pairing.",
})
@@ -1314,9 +1494,14 @@ func renderPreResetNote() {
fmt.Println()
}
func renderPlanSteps(steps []planStep) {
func renderPlanSteps(steps []planStep, includePair bool) {
if len(steps) == 0 {
PrintSuccess("Speaker is already migrated and paired. No action required.")
if includePair {
PrintSuccess("Speaker is already migrated and paired. No action required.")
} else {
PrintSuccess("Speaker is already migrated. No action required.")
}
return
}
+50
View File
@@ -3,6 +3,8 @@ package main
import (
"encoding/base64"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -685,3 +687,51 @@ func boolToStatus(b bool) string {
return "❌ No"
}
// notifySourcesUpdated POSTs a sourcesUpdated notification directly to the
// speaker's :8090/notification endpoint. The speaker re-fetches its source
// list from AfterTouch immediately. Requires network access to the speaker.
func notifySourcesUpdated(c *cli.Context) error {
if err := RequireHost(c); err != nil {
return err
}
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
info, err := client.GetDeviceInfo()
if err != nil {
return fmt.Errorf("failed to get device info from %s: %w", clientConfig.Host, err)
}
body := fmt.Sprintf(`<updates deviceID="%s"><sourcesUpdated/></updates>`, info.DeviceID)
notifyURL := fmt.Sprintf("http://%s:8090/notification", clientConfig.Host)
req, err := http.NewRequest(http.MethodPost, notifyURL, strings.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/xml")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("post to speaker: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return fmt.Errorf("speaker returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
PrintSuccess(fmt.Sprintf("Sent sourcesUpdated to %s (%s)", info.DeviceID, clientConfig.Host))
return 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
}
+27 -1
View File
@@ -19,6 +19,16 @@ import (
"github.com/urfave/cli/v2"
)
// CloudCommonFlags defines flags for commands that talk to the AfterTouch service.
var CloudCommonFlags = []cli.Flag{
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service URL",
Required: true,
EnvVars: []string{"AFTERTOUCH_URL"},
},
}
// CommonFlags defines flags that are shared across multiple commands
var CommonFlags = []cli.Flag{
&cli.StringFlag{
@@ -322,7 +332,7 @@ func PrintSuccess(message string) {
// PrintError prints a standard error message
func PrintError(message string) {
fmt.Printf("✗ %s\n", message)
fmt.Printf("✗ %s\n", sanitizeLog(message))
}
// PrintWarning prints a standard warning message
@@ -330,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)
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+88 -4
View File
@@ -132,6 +132,11 @@ func main() {
Aliases: []string{"a"},
Usage: "Show detailed information for all devices",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Print per-packet/per-header SSDP and mDNS trace logs",
},
},
},
},
@@ -380,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,
},
@@ -579,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{
@@ -604,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{
@@ -618,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{
@@ -637,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{
@@ -1141,6 +1214,12 @@ func main() {
Action: introspectAllServices,
Before: RequireHost,
},
{
Name: "notify-updated",
Usage: "Tell the speaker to re-fetch its source list from AfterTouch",
Action: notifySourcesUpdated,
Before: RequireHost,
},
},
},
// Bass commands
@@ -1862,6 +1941,7 @@ func main() {
Action: playNotificationBeep,
Before: RequireHost,
},
ttsCloudCmd(),
{
Name: "help",
Usage: "Show detailed help about speaker functionality",
@@ -2233,6 +2313,10 @@ func main() {
// Defined in cmd_setup.go to keep the top-level command list readable.
app.Commands = append(app.Commands, setupCommand())
// AfterTouch service management (sources, accounts, devices).
// Defined in cmd_cloud.go.
app.Commands = append(app.Commands, cloudCommand())
// Sort commands alphabetically (including subcommands and flags recursively)
sortCommands(app.Commands)
+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
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
@@ -1,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")
@@ -86,7 +123,7 @@ func main() {
}
if rawBind != "" && bindAddr != rawBind {
log.Printf("Resolved --bind %q to %s", rawBind, bindAddr)
log.Printf("Resolved --bind %q to %s", sanitizeLog(rawBind), sanitizeLog(bindAddr))
}
rawIface := c.String("interface")
@@ -94,7 +131,7 @@ func main() {
ifaceName := defaultDiscoveryInterface(rawIface, rawBind, bindAddr)
if rawIface == "" && ifaceName != "" {
log.Printf("Defaulting --interface to %q from --bind", ifaceName)
log.Printf("Defaulting --interface to %q from --bind", sanitizeLog(ifaceName))
}
addr := ":" + 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)
@@ -131,7 +180,7 @@ func main() {
r := chi.NewRouter()
webApp.Mount(r, discoveryService)
log.Printf("AfterTouch Web UI starting on http://%s", addr)
log.Printf("AfterTouch Web UI starting on http://%s", sanitizeLog(addr))
return http.ListenAndServe(addr, r)
},
@@ -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
}
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -90,3 +90,100 @@ func TestApplyPersistedSettings(t *testing.T) {
}
})
}
func TestMergeTLSExtraHosts(t *testing.T) {
cases := []struct {
name string
cli []string
persisted []string
want []string
}{
{
name: "CLI only",
cli: []string{"a.example"},
persisted: nil,
want: []string{"a.example"},
},
{
name: "Persisted only",
cli: nil,
persisted: []string{"b.example"},
want: []string{"b.example"},
},
{
name: "CLI wins ordering, persisted appended",
cli: []string{"a.example"},
persisted: []string{"b.example"},
want: []string{"a.example", "b.example"},
},
{
name: "Dedupes overlap",
cli: []string{"a.example", "b.example"},
persisted: []string{"b.example", "c.example"},
want: []string{"a.example", "b.example", "c.example"},
},
{
name: "Drops empty + whitespace",
cli: []string{" ", "a.example", ""},
persisted: []string{"", " b.example "},
want: []string{"a.example", "b.example"},
},
{
name: "Both empty",
cli: nil,
persisted: nil,
want: []string{},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := mergeTLSExtraHosts(tc.cli, tc.persisted)
if len(got) != len(tc.want) {
t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("index %d: got %q, want %q (full: %v vs %v)", i, got[i], tc.want[i], got, tc.want)
}
}
})
}
}
func TestGetDomains_IncludesOAuthDerivation(t *testing.T) {
// Hostname-based serverURL: the derived OAuth variant must end up
// in the served TLS cert SAN list, otherwise the speaker rejects
// the TLS handshake on Spotify / Amazon Music token refresh.
got := getDomains("http://mac.fritz.box:8000", "https://mac.fritz.box:8443", "mac.fritz.box", nil)
want := "macoauth.fritz.box"
if !contains(got, want) {
t.Errorf("expected SAN list to include %q (derived from serverURL), got: %v", want, got)
}
}
func TestGetDomains_IPServerURLProducesNoOAuthDerivation(t *testing.T) {
// IP-based serverURL deliberately yields no derivation (the speaker's
// `<first-label>oauth.<rest>` construction would be malformed for an
// IP and no DNS resolver can answer for it). The cert SAN list must
// not pretend to cover something that can never be queried.
got := getDomains("http://192.168.0.30:8000", "https://192.168.0.30:8443", "192.168.0.30", nil)
for _, h := range got {
if h == "192oauth.168.0.30" {
t.Errorf("SAN list must not include malformed IP-derived OAuth name, got: %v", got)
}
}
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
+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/*
+138 -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
@@ -9,29 +17,88 @@ DELETE /setup/devices/{deviceId} handlers.(
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
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
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
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
@@ -41,6 +108,8 @@ GET /docs/* handlers.(
GET /favicon.ico setupRouter
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
@@ -53,19 +122,24 @@ GET /mgmt/spotify/callback handlers.(
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
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
@@ -87,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
@@ -95,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
@@ -127,6 +245,8 @@ 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
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
@@ -140,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
+13
View File
@@ -0,0 +1,13 @@
package main
import "strings"
// sanitizeLog strips newline characters from s to prevent log-injection
// (CodeQL go/log-injection). Values from speakers, HTTP requests, and
// external APIs may contain attacker-controlled newlines.
func sanitizeLog(s string) string {
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, "\r", `\r`)
return s
}
+1 -1
View File
@@ -573,7 +573,7 @@ type VerboseLogger struct{}
func (v *VerboseLogger) Printf(format string, args ...interface{}) {
timestamp := time.Now().Format("15:04:05")
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, fmt.Sprintf(format, args...))
fmt.Printf("[%s] [WebSocket] %s\n", timestamp, sanitizeLog(fmt.Sprintf(format, args...)))
}
// SilentLogger provides no-op WebSocket logging
+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:
+34
View File
@@ -0,0 +1,34 @@
# Local Hugo/Hextra documentation server.
#
# Usage:
# make dev-docs # start the live-reload server (http://localhost:1313)
# make dev-docs-tidy # run hugo mod tidy (required on first run, or after
# # changing hugo.toml module imports)
# make hugo ARGS="..." # run any other hugo CLI command, e.g.
# # make hugo ARGS="version"
# # make hugo ARGS="new content/blog/my-post.md"
#
# The hugomods/hugo:exts image bundles Hugo extended + Go so Hugo modules
# (Hextra) work without any extra tooling on the host.
services:
hugo:
image: hugomods/hugo:exts
# --source docs/ because docs/ is the Hugo root inside the repo.
# --baseURL / overrides the production subpath (/Bose-SoundTouch/) so
# absolute links work at http://localhost:1313/ during local development.
# The full repo is mounted so enableGitInfo can read git history.
command: server --source docs/ --baseURL / --bind 0.0.0.0 --buildDrafts --navigateToChanged
ports:
- "1313:1313"
volumes:
- .:/src
# Persist the Hugo module cache across runs so 'hugo mod tidy' only
# downloads Hextra once.
- hugo-mod-cache:/root/.cache/hugo_cache
working_dir: /src
environment:
- HUGO_PARAMS_GITHASH
volumes:
hugo-mod-cache:
-108
View File
@@ -1,108 +0,0 @@
# Table of Contents
* [Introduction](README.md)
## User Guides
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Self-Hosting AfterTouch](guides/SELF-HOSTING.md)
* [Connecting Music Services](guides/MUSIC-SERVICES.md)
* [Migration & Safety Guide](guides/MIGRATION-SAFETY.md)
* [CLI Reference](guides/CLI-REFERENCE.md)
* [Backup Tool](../cmd/soundtouch-backup/README.md)
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [Capture Device Pairing Traffic](guides/CAPTURE-DEVICE-PAIRING.md)
* [Capture Migration Traffic](guides/CAPTURE-MIGRATION-TRAFFIC.md)
* [Device Setup Flow](DEVICE-SETUP.md)
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
* [Raspberry Pi Guide](guides/RASPBERRY-PI.md)
* [Troubleshooting](guides/TROUBLESHOOTING.md)
* [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md)
* [Migration Guide](guides/MIGRATION-GUIDE.md)
* [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md)
* [Useful Links](#useful-links)
### Useful Links
* [Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)
* [Raspberry Pi Installer](../scripts/raspberry-pi/README.md)
* [Updating the Service](../scripts/raspberry-pi/README.md#updating-to-a-new-version)
* [CLI Reference](guides/CLI-REFERENCE.md)
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Spotify Account Addition](reference/spotify-account-addition.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
* [WebSocket Events](reference/WEBSOCKET-EVENTS.md)
* [Device Pairing Flow](reference/DEVICE-PAIRING-FLOW.md)
* [Discovery](reference/DISCOVERY.md)
* [Zone Management](reference/ZONE-MANAGEMENT.md)
* [Preset Management](reference/PRESET-MANAGEMENT.md)
* [Source Selection](reference/SOURCE-SELECTION.md)
* [Volume Controls](reference/VOLUME-CONTROLS.md)
* [RadioBrowser](reference/radio-browser.md)
* [Bass Controls](reference/BASS-CONTROLS.md)
* [Key Controls](reference/KEY-CONTROLS.md)
* [Feature Mapping](reference/FEATURE-MAPPING.md)
## Concepts
* [Request Recording](REQUEST_RECORDING_CONCEPT.md)
* [Spotify Priming Strategy](concepts/spotify-priming-strategy.md)
* [Spotify OAuth](concepts/spotify-oauth.md)
* [soundtouch-web Roadmap](soundtouch-web-roadmap.md)
## Analysis & Research
* [API Coverage Analysis](analysis/API-COVERAGE.md)
* [Supported URLs](analysis/SUPPORTED-URLS.md)
* [Upstream URLs](analysis/UPSTREAM-URLS.md)
* [Anonymization Summary](analysis/ANONYMIZATION-SUMMARY.md)
* [Device Redirect Methods](analysis/DEVICE-REDIRECT-METHODS.md)
* [Telnet (Port 17000) Migration Method](analysis/TELNET-MIGRATION-METHOD.md)
* [Telnet Command Reference](analysis/TELNET-COMMAND-REFERENCE.md)
* [Setup WebSocket Experiment](analysis/SETUP-WEBSOCKET-EXPERIMENT.md)
* [Factory Reset Protocol](analysis/FACTORY-RESET-PROTOCOL.md)
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
* [Bose App ADB Emulator](analysis/BOSE-APP-ADB-Emulator.md)
* [Community Tools](analysis/bose-soundtouch-community-tools.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
## Appendix (Other Documents)
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
* [Device Customization Setup](DEVICE-CUSTOMIZATION-SETUP.md)
* [Device Logging](DEVICE-LOGGING.md)
* [Feature History](FEATURE_HISTORY.md)
* [Host/Port Parsing](HOST-PORT-PARSING.md)
* [Manual Network Discovery](MANUAL-NETWORK-DISCOVERY.md)
* [Navigation Guide](NAVIGATION-GUIDE.md)
* [Official API Verification](OFFICIAL-API-VERIFICATION.md)
* [Preset Quickstart](PRESET-QUICKSTART.md)
* [Project Patterns](PROJECT-PATTERNS.md)
* [Service Availability Implementation](SERVICE-AVAILABILITY-IMPLEMENTATION.md)
* [SoundTouch Service Announcement](SOUNDTOUCH-SERVICE-ANNOUNCEMENT.md)
* [Undocumented Community Features](UNDOCUMENTED-COMMUNITY-FEATURES.md)
* [Unimplemented Endpoints](UNIMPLEMENTED-ENDPOINTS.md)
* [Preset Store](preset-store.md)
* [SCMUDC Enrichment Implementation](SCMUDC-ENRICHMENT-IMPLEMENTATION.md)
* [Device Lifecycle and Power On Enhancement](device-lifecycle-and-power-on-enhancement.md)
* [Device Lifecycle Summary](device-lifecycle-summary.md)
* [Power On Implementation Guide](power-on-implementation-guide.md)
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Stockholm Port Guide](stockholm-port-guide.md)
-11
View File
@@ -1,11 +0,0 @@
title: Bose SoundTouch Toolkit
description: Documentation for controlling and preserving Bose SoundTouch devices
remote_theme: pages-themes/minimal@v0.2.0
plugins:
- jekyll-remote-theme
- jekyll-relative-links
relative_links:
enabled: true
collections: true
include:
- SUMMARY.md
+4 -4
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
@@ -784,4 +784,4 @@ docker compose up # Mock devices + web app
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [Go Embed Directive](https://pkg.go.dev/embed)
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
- [PROJECT-PATTERNS.md](../content/docs/appendix/PROJECT-PATTERNS.md) - Detailed pattern documentation
+46
View File
@@ -0,0 +1,46 @@
/* Font paths use ../../fonts/ (two levels up) so the URL resolves correctly
regardless of where the CSS is served from:
- dev: /css/custom.css ../../fonts/ /fonts/
- production: /css/compiled/main.css ../../fonts/ /fonts/
- GH Pages: /Bose-SoundTouch/css/compiled/main.css
../../fonts/ /Bose-SoundTouch/fonts/ */
@font-face {
font-family: 'Noto Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../../fonts/noto-sans-v42-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('../../fonts/noto-sans-v42-latin-italic.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../../fonts/noto-sans-v42-latin-700.woff2') format('woff2');
}
@font-face {
font-family: 'Noto Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('../../fonts/noto-sans-v42-latin-700italic.woff2') format('woff2');
}
:root {
--hx-default-font-family: "Noto Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.content {
font-family: var(--hx-default-font-family);
}
+65
View File
@@ -0,0 +1,65 @@
---
title: AfterTouch
layout: hextra-home
---
{{< hextra/hero-badge >}}
<div class="hx-w-2 hx-h-2 hx-rounded-full hx-bg-primary-400"></div>
<span>Free, open source</span>
{{< icon name="arrow-circle-right" attributes="height=14" >}}
{{< /hextra/hero-badge >}}
<div class="hx-mt-6 hx-mb-6">
{{< hextra/hero-headline >}}
Keep Your Bose SoundTouch&nbsp;Speakers Alive
{{< /hextra/hero-headline >}}
</div>
<div class="hx-mb-12">
{{< hextra/hero-subtitle >}}
Bose shut down SoundTouch cloud services on May 6, 2026.&nbsp;<br class="sm:hx-block hx-hidden" />AfterTouch replaces the cloud — presets, music browsing, stereo pairing, all restored.
{{< /hextra/hero-subtitle >}}
</div>
<div class="hx-mb-6">
{{< hextra/hero-button text="Get Started" link="docs/guides/MIGRATION-GUIDE" >}}
{{< hextra/hero-button text="Survival Guide" link="docs/guides/SURVIVAL-GUIDE" style="outline" >}}
</div>
<div class="hx-mt-6">
{{< hextra/feature-grid >}}
{{< hextra/feature-card
title="Presets Restored"
subtitle="Preset buttons, long-press assignment, and recently-played sync — fully working."
icon="star"
>}}
{{< hextra/feature-card
title="Music Browsing"
subtitle="TuneIn, Internet Radio, RadioBrowser, and Spotify via soundtouch-player and soundtouch-cli."
icon="speakerphone"
>}}
{{< hextra/feature-card
title="Stereo Pairing"
subtitle="SoundTouch 10 stereo pairing via soundtouch-cli, no Bose cloud required."
icon="adjustments"
>}}
{{< hextra/feature-card
title="Three Deployment Options"
subtitle="Run on a Raspberry Pi, a VPS, or directly on the speaker itself."
icon="server"
link="docs/guides/DEPLOYMENT-OVERVIEW"
>}}
{{< hextra/feature-card
title="CLI Control"
subtitle="soundtouch-cli for scripting, home automation, and direct device control."
icon="terminal"
link="docs/guides/CLI-REFERENCE"
>}}
{{< hextra/feature-card
title="Open Source"
subtitle="MIT licensed. Not affiliated with Bose Corporation."
icon="shield-check"
link="https://github.com/gesellix/Bose-SoundTouch"
>}}
{{< /hextra/feature-grid >}}
</div>
+120
View File
@@ -0,0 +1,120 @@
---
title: "Welcome to AfterTouch: Your SoundTouch Speakers, Still Alive"
date: 2026-05-24
description: "Bose shut down SoundTouch cloud services in May 2026. AfterTouch replaces everything your speakers relied on — migration, radio, Spotify, presets, and more."
tags:
- migration
- web
- spotify
- cli
sidebar:
exclude: true
---
On May 6, 2026, Bose shut down the SoundTouch cloud services that millions of speakers
depended on for account sync, presets, internet radio, and streaming. Speakers kept
working locally, but remote features stopped and first-time setup became impossible.
AfterTouch was built to change that. It is a self-hosted replacement for the Bose
cloud infrastructure — a drop-in local service that your speakers talk to instead of
`streaming.bose.com`. This post covers what works today and how to get started.
## What works right now
### Migration and first-time setup
If your speaker was registered with Bose before the shutdown, AfterTouch can **migrate
your existing account and presets** in a single step — no reconfiguration on the
speaker side. If you are setting up a factory-reset or brand-new speaker, AfterTouch
handles that path too, guiding you through Wi-Fi pairing and account creation locally.
See the [Migration Guide](../docs/guides/MIGRATION-GUIDE.md) for step-by-step instructions.
### Internet radio — TuneIn and RadioBrowser
Both **TuneIn** and **RadioBrowser** are fully supported for browsing and playback.
Navigate categories and search for stations exactly as you did with the original Bose
app. TuneIn delivers the same station catalogue; RadioBrowser provides an open,
community-maintained alternative.
### Spotify
**Spotify** works via both OAuth (account linking) and Spotify Connect (the ZeroConf
"connect to device" flow from the Spotify app). Once linked, playback and device
selection behave the same as before.
### Presets
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-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-player — browser UI
**soundtouch-player** is an early-stage but functional browser UI bundled with AfterTouch.
It gives you:
- TuneIn and RadioBrowser browsing and playback
- Speaker management and device discovery
- Recent tracks panel
- Multi-room zone management
It runs as part of the AfterTouch service — no separate install needed.
![soundtouch-player UI showing Spotify playback, presets, sources, and zone management](/images/blog/soundtouch-player-ui.png)
### Automation with soundtouch-cli
The **`soundtouch-cli`** command-line tool covers every speaker control: play, pause,
volume, source selection, preset recall, group management, migration, and more.
It is well-suited for home-automation scripts, cron jobs, and shell one-liners.
## Three ways to install
AfterTouch runs on any machine your speakers can reach:
1. **On the speaker itself** — install directly on supported SoundTouch hardware via
the on-device installer. The speaker hosts its own replacement cloud, with no
additional hardware required.
2. **On a local network host** — run AfterTouch on any machine on your LAN. A
**Raspberry Pi Zero 2W** handles the load without breaking a sweat, making this
path remarkably low-cost and low-power.
3. **On a cloud or VPS host** — deploy to a remote server for access outside your
home network. AfterTouch handles TLS certificate generation and DNS configuration
for this scenario.
All three paths are documented in the [Deployment Overview](../docs/guides/DEPLOYMENT-OVERVIEW.md).
## Current release
**v0.93.1** — released May 24, 2026
## Community
AfterTouch would not be where it is without the people who opened issues, tested
pre-release builds, reported edge cases, and contributed code. A significant share of
the fixes and features shipped in the lead-up to the cloud shutdown were driven by
real-world feedback from the community — from migration quirks to stereo-pair
specifics to Spotify Connect timing issues. Thank you to everyone who helped.
If you run into something or have an idea, the
[GitHub issue tracker](https://github.com/gesellix/Bose-SoundTouch/issues) and
[Discussions](https://github.com/gesellix/Bose-SoundTouch/discussions) are the
right places to start.
## What's next
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-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.
Subscribe to the [GitHub releases](https://github.com/gesellix/Bose-SoundTouch/releases)
for individual version notes.
+5
View File
@@ -0,0 +1,5 @@
---
title: News & Updates
---
Project updates, release notes, and development notes for AfterTouch — the local replacement for the Bose SoundTouch cloud.
+17 -12
View File
@@ -1,3 +1,9 @@
---
title: Introduction
sidebar:
open: true
---
# Bose SoundTouch Toolkit Documentation
Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive toolkit helps you keep your Bose SoundTouch speakers functional even after the Bose Cloud shutdown in May 2026, with enhanced local management and monitoring capabilities.
@@ -10,7 +16,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
### For Existing Users
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
- **[Backup Tool](../cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
- **[Backup Tool](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-backup/README.md)** - Back up your cloud account and speaker data before shutdown
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
## 📋 Essential Documentation
@@ -41,7 +47,7 @@ The documentation is organized into three main categories:
### Advanced Features
- [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md) - Device identification
- [CLI Reference](guides/CLI-REFERENCE.md) - Command-line tools
- [Backup Tool](../cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
- [Backup Tool](https://github.com/gesellix/Bose-SoundTouch/blob/main/cmd/soundtouch-backup/README.md) - Cloud account and speaker data backup
- [IoT Implementation Guide](guides/IOT-IMPLEMENTATION-GUIDE.md) - IoT integrations
- [MQTT Integration Design](guides/MQTT-INTEGRATION-DESIGN.md) - MQTT setup
@@ -61,19 +67,18 @@ The documentation is organized into three main categories:
- [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md) - Configuration summaries
### Device Lifecycle & Network Independence
- **[Device Lifecycle and /power_on Enhancement](device-lifecycle-and-power-on-enhancement.md)** - Complete analysis of device registration and network independence improvements
- [/power_on Implementation Guide](power-on-implementation-guide.md) - Technical implementation details for enhanced device management
- **[Device Lifecycle and /power_on Enhancement](appendix/device-lifecycle-and-power-on-enhancement.md)** - Complete analysis of device registration and network independence improvements
- [/power_on Implementation Guide](appendix/power-on-implementation-guide.md) - Technical implementation details for enhanced device management
## 🏗 Concept Documentation
### Enhanced Service Architecture
- **[Concept Overview](concepts/README.md)** - High-level architecture vision
- [Upstream Service Simulation](concepts/upstream-service-simulation.md) - Complete concept design
- [Implementation Plan](concepts/implementation-plan.md) - Development roadmap
- [Technical Specification](concepts/technical-specification.md) - Detailed specifications
- [Spotify Overview](concepts/spotify-overview.md) — mental model, Spotify Connect vs OAuth-intercept, DNS rewrite gotcha
- [Spotify OAuth](concepts/spotify-oauth.md) — flows and management endpoints
- [Amazon Music OAuth](concepts/amazon-music-oauth.md) — companion to Spotify OAuth; same protocol shape, different scopes
- [Encrypted Export](concepts/ENCRYPTED-EXPORT.md) — `.age`-encrypted diagnostic bundles
- [Request Recording](appendix/REQUEST_RECORDING_CONCEPT.md) — how the proxy captures live device traffic for parity testing
### Development Planning
- [Implementation Roadmap](concepts/implementation-roadmap.md) - Project phases and milestones
Older planning artefacts ("Enhanced State Management System", "Upstream Service Simulation") live under [docs/archive/](../../archive/) — kept for the record, no longer current.
## 💡 Quick Reference
@@ -89,4 +94,4 @@ The documentation is organized into three main categories:
- **Documentation**: Check troubleshooting guides first
- **Community**: Share experiences and help others
For a complete list of all documents, see the [Summary](SUMMARY.md).
For a complete list of all documents, browse the sections in the sidebar.
@@ -1,5 +1,6 @@
# Placeholder values for examples
---
title: "Placeholder values for examples"
---
This repo is public. Documentation, READMEs, example configs, and test
fixtures must never carry real LAN IPs, real device MACs, real Bose
account IDs, or personal device names from any maintainer or
@@ -1,12 +1,13 @@
# Bose SoundTouch API Coverage Analysis
**Last Updated:** February 2026
---
title: "Bose SoundTouch API Coverage Analysis"
---
**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**
@@ -1,5 +1,6 @@
# Bose SoundTouch Traffic Interception Runbook
---
title: "Bose SoundTouch Traffic Interception Runbook"
---
Intercept HTTPS/WebSocket traffic from the Bose SoundTouch Android app using an Android emulator, mitmproxy, and Frida. Tested on Apple Silicon (ARM64) Mac.
## Automated Setup
@@ -1,5 +1,6 @@
# Bose SoundTouch Traffic Analysis Runbook
---
title: "Bose SoundTouch Traffic Analysis Runbook"
---
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
---
@@ -83,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
@@ -103,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)
```
---
@@ -150,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
@@ -236,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:
@@ -258,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
@@ -321,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)
@@ -350,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:**
@@ -606,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)**
@@ -666,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.**
@@ -682,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.
@@ -705,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
@@ -888,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
```
@@ -1,5 +1,6 @@
# Device Redirect Methods & Custom Service Setup
---
title: "Device Redirect Methods & Custom Service Setup"
---
To enable offline operation or use custom services like **SoundCork** or **ÜberBöse API**, SoundTouch devices must be redirected from Bose's official cloud endpoints to a local or custom server. This document outlines the three known methods to achieve this, gathered from community reverse-engineering efforts in the **SoundCork** and **ÜberBöse API** projects.
> A fourth, **SSH-free** path — driving the device's diagnostic shell on TCP port 17000 — is being added as a peer to the XML and DNS methods. See **[TELNET-MIGRATION-METHOD.md](TELNET-MIGRATION-METHOD.md)** for the use cases, community findings, and feasibility analysis. The `/etc/hosts` method documented below is now deprecated and will not be exposed in the web UI.
@@ -1,5 +1,6 @@
# What a SoundTouch speaker does during factory reset
---
title: "What a SoundTouch speaker does during factory reset"
---
Observed live on ST10 firmware `27.0.6.46330.5043500` (build `epdbuild.trunk.hepdswbld04.2022-08-04`) on 2026-05-12, by running `soundtouch-cli setup factory-reset` and tailing the speaker's `logread` over SSH. The trace is preserved at `_/logs/factory-reset.txt` for reference.
## Sequence
@@ -1,5 +1,6 @@
# IoT Configuration Quick Reference
---
title: "IoT Configuration Quick Reference"
---
## Key Files and Locations
| File/Location | Purpose | Notes |
@@ -1,5 +1,6 @@
# IoT Configuration Analysis
---
title: "IoT Configuration Analysis"
---
## Overview
This document provides a detailed analysis of the AWS IoT configuration system used by Bose SoundTouch devices, based on firmware backup analysis from ST10 and ST20 models.
@@ -1,5 +1,6 @@
# Spotify Account Addition Implementation Status
---
title: "Spotify Account Addition Implementation Status"
---
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
## 1. OAuth Token Exchange (Bose Cloud)
@@ -1,5 +1,6 @@
# Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?
---
title: "Experiment: Does bare `setMargeAccount` work outside the SETUP bracket?"
---
## Why we are doing this
Our captured pairing flow (`docs/reference/DEVICE-PAIRING-FLOW.md`) shows the official Bose app always sends `setMargeAccount` *inside* a `SETUP_START``SETUP_ENTER``SETUP_LEAVE` state-machine bracket over WebSocket. The question this experiment answers:
@@ -1,11 +1,25 @@
# SoundTouch supportedURLs Endpoint Analysis
---
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:**
@@ -60,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)
@@ -90,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
@@ -118,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
@@ -132,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)
@@ -155,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
@@ -228,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 ... -->
@@ -1,5 +1,6 @@
# Bose SoundTouch Telnet (Port 17000) Command Reference
---
title: "Bose SoundTouch Telnet (Port 17000) Command Reference"
---
A consolidated reference for the diagnostic shell that listens on TCP port
17000 across the SoundTouch line. Compiled from multiple community sources
to give a single map of what's been observed in the wild — useful both for
@@ -1,5 +1,6 @@
# Telnet (Port 17000) Migration Method — Analysis
---
title: "Telnet (Port 17000) Migration Method — Analysis"
---
This document captures the use cases, community findings, and feasibility analysis
for adding a **Telnet/port 17000** migration path to `soundtouch-service` as a
peer of the existing XML and DNS-based methods. The `/etc/hosts` method stays
@@ -8,7 +9,7 @@ deprecated and is intentionally kept off the visible UI options.
> **Sources** — community discussion synthesised from
> [gesellix/Bose-SoundTouch#221](https://github.com/gesellix/Bose-SoundTouch/issues/221),
> [gesellix/Bose-SoundTouch#236](https://github.com/gesellix/Bose-SoundTouch/issues/236),
> [scheilch/opencloudtouch#167](https://github.com/scheilch/opencloudtouch/issues/167),
> [scheilch/opencloudtouch#167](https://github.com/opencloudtouch/opencloudtouch/issues/167),
> [deborahgu/soundcork#228](https://github.com/deborahgu/soundcork/issues/228),
> [deborahgu/soundcork#141](https://github.com/deborahgu/soundcork/issues/141),
> the post-EOS walkthrough PDF in `docs/`,
@@ -1,5 +1,6 @@
# Upstream URLs & Domains Analysis
---
title: "Upstream URLs & Domains Analysis"
---
This document provides a comprehensive overview of the upstream Bose cloud services and domains that SoundTouch devices communicate with. These details were gathered from firmware analysis of ST10/ST20 devices, binary string extraction, and community research from the **SoundCork** project (Issue #128).
## Core Service Domains
@@ -1,5 +1,6 @@
# SoundTouch API Comparison: Community Wiki vs Current Implementation
---
title: "SoundTouch API Comparison: Community Wiki vs Current Implementation"
---
**Date:** January 2026
**Source:** [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
**Our Implementation:** Bose-SoundTouch Go Library v1.0
+4
View File
@@ -0,0 +1,4 @@
---
title: "Analysis & Research"
weight: 4
---
@@ -1,5 +1,6 @@
# Bose SoundTouch — Community Tools for Post-EOL Preservation
---
title: "Bose SoundTouch — Community Tools for Post-EOL Preservation"
---
> **Context:** Bose announced the shutdown of SoundTouch cloud services, extended to **May 6, 2026**. On that date the official SoundTouch app will update to a local-only version. Bose has released the [SoundTouch Web API documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf) as open-source to enable community-driven development. This document surveys the active community projects, their feature coverage, and open development opportunities.
---
@@ -197,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.
@@ -233,7 +234,7 @@ A design document exists (`docs/guides/MQTT-INTEGRATION-DESIGN.md`) but no code
## soundcork ↔ AfterTouch
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../PARITY-SOUNDCORK.md).
soundcork and AfterTouch share the most functional overlap of any two projects in the ecosystem. For the implementation-level parity analysis and remaining tasks see [docs/PARITY-SOUNDCORK.md](../appendix/PARITY-SOUNDCORK.md).
### Architectural differences (not gaps)
@@ -241,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) |
@@ -1,5 +1,8 @@
# Navigation API Reference
---
title: "Navigation API Reference"
sidebar:
exclude: true
---
## Overview
This document provides a complete API reference for the Bose SoundTouch navigation and station management functionality. For usage examples and workflows, see [NAVIGATION-GUIDE.md](NAVIGATION-GUIDE.md).
@@ -1,13 +1,15 @@
# CLAUDE.md - Development Guidelines for Bose SoundTouch Project
---
title: "CLAUDE.md - Development Guidelines for Bose SoundTouch Project"
sidebar:
exclude: true
---
## Documentation Overview
This document contains important development guidelines for working on the Bose SoundTouch project. Please also read the following documentation:
- **[PLAN.md](archive/PLAN.md)** - Project planning and roadmap
- **[PLAN.md](../../../archive/PLAN.md)** - Project planning and roadmap
- **[PROJECT-PATTERNS.md](PROJECT-PATTERNS.md)** - Project structure and design patterns
- **[API-ENDPOINTS.md](reference/API-ENDPOINTS.md)** - API endpoints overview
- **[SoundTouch Web API.pdf](2025.12.18%20SoundTouch%20Web%20API.pdf)** - Official API documentation
- **[API-ENDPOINTS.md](../reference/API-ENDPOINTS.md)** - API endpoints overview
## Development Guidelines
@@ -1,5 +1,8 @@
# Content Selection Implementation Summary
---
title: "Content Selection Implementation Summary"
sidebar:
exclude: true
---
This document summarizes the implementation of advanced content selection features for the Bose SoundTouch Go client, including full support for the LOCAL_INTERNET_RADIO streamUrl format and LOCAL_MUSIC/STORED_MUSIC content selection.
## ✅ Implementation Status: COMPLETE
@@ -212,9 +215,9 @@ soundtouch-cli --host 192.0.2.100 source internet-radio \
- [SoundTouch WebServices API Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)
- [LOCAL_INTERNET_RADIO - streamUrl format](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_internet_radio---streamurl-format)
- [LOCAL_MUSIC](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API#select-local_music)
- [Content Selection Example](../examples/content-selection/README.md)
- [CLI Reference](guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](../examples/content-selection/)
- [Content Selection Example](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/content-selection/README.md)
- [CLI Reference](../guides/CLI-REFERENCE.md)
- [Content Selection Example (Direct)](https://github.com/gesellix/Bose-SoundTouch/tree/main/examples/content-selection)
## ✅ Verification
@@ -1,5 +1,8 @@
# Device Customization Setup Guide
---
title: "Device Customization Setup Guide"
sidebar:
exclude: true
---
This guide documents the manual steps required to configure your Bose SoundTouch device for customization using the SoundCork approach.
Based on: https://github.com/deborahgu/soundcork
@@ -1,5 +1,8 @@
# Device Logging & Troubleshooting
---
title: "Device Logging & Troubleshooting"
sidebar:
exclude: true
---
Accessing logs from SoundTouch devices is critical for debugging custom service integrations and understanding internal device behavior. This document outlines the methods for collecting logs, as discovered by the **SoundCork** and **ÜberBöse API** communities.
## Log Types
@@ -80,7 +83,7 @@ If you have a managed switch or a router capable of port mirroring, you can use
### "IsItBose" Validation Failures
If the device fails to connect to your custom service despite correct configuration, it may be failing the internal `IsItBose` regex check.
- **Evidence**: Look for SSL handshake failures or "Unauthorized" errors in your service logs.
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
- **Solution**: See the [Binary Patching section in DEVICE-REDIRECT-METHODS.md](../analysis/DEVICE-REDIRECT-METHODS.md#method-3-binary-patching).
### Disappearing Sources (TuneIn/Local Radio)
If `TUNEIN` or `LOCAL_INTERNET_RADIO` sources disappear after a reboot in an offline environment.
@@ -1,5 +1,8 @@
# Bose SoundTouch Device Setup Flow
---
title: "Bose SoundTouch Device Setup Flow"
sidebar:
exclude: true
---
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
@@ -0,0 +1,141 @@
---
title: "Encrypted Diagnostic Export"
sidebar:
exclude: true
---
AfterTouch can produce an encrypted diagnostic report that users can download and
send to the project maintainer without exposing sensitive data to third parties.
The report is encrypted with an SSH public key using
[`age`](https://github.com/FiloSottile/age); only the holder of the matching
private key can read it.
---
## What the report contains
The encrypted `.age` file decrypts to a `.tar.gz` archive with:
- `diagnostic.json` — structured summary:
- Service version and build info
- Full health-check results (same data as the Health tab)
- Per-device state: sources (IDs, names, SourceKeyTypes), presets (slot, name,
Source, SourceID, location), device product code, firmware version, IP, name
- `datastore/accounts/{id}/devices/{id}/*.xml` — raw XML files verbatim from
the sender's datastore (`Presets.xml`, `Sources.xml`, `Recents.xml`, …)
Having both the structured JSON and the raw XML lets you compare what the
service serves via HTTP against what is actually stored on disk.
**What is excluded from the JSON:** authentication tokens, credentials, OAuth
secrets, Spotify refresh tokens. The raw XML files are included as-is.
---
## Maintainer setup (one-time)
> This section is for the project maintainer only.
> Users never need to touch keys.
### 1. Generate the key pair
```bash
bash scripts/setup-diagnostic-key.sh
```
This creates:
- `keys/private/diagnostic` — SSH ed25519 private key (**gitignored**, never commit)
- `keys/private/diagnostic.pub` — copy for reference (**gitignored**)
- `keys/public/diagnostic.pub` — public key committed to the repo
### 2. Add the public key to GitHub
Go to <https://github.com/settings/ssh/new> and paste the contents of
`keys/public/diagnostic.pub`. This makes the key visible at
<https://github.com/gesellix.keys> so users can independently verify that the
key embedded in the binary matches a key actually controlled by the maintainer.
### 3. Embed the public key in the binary
Open `pkg/service/export/encrypt.go` and update the `DiagnosticPublicKey`
constant to match the new public key:
```go
const DiagnosticPublicKey = "ssh-ed25519 AAAA... aftertouch-diagnostic@gesellix"
```
### 4. Commit
```bash
git add keys/public/diagnostic.pub pkg/service/export/encrypt.go
git commit -m "keys: add diagnostic SSH public key"
```
`keys/private/` is `.gitignore`d — the private key will not be committed.
### 5. Back up the private key
The private key is **not** stored in git. Keep a copy in a secure location
(password manager, encrypted USB drive, etc.). If it is lost, a new key pair
must be generated and the constant in `encrypt.go` updated.
---
## Verifying the embedded key (users)
Users who want to confirm that the key embedded in their running binary matches
the maintainer's GitHub SSH keys can run:
```bash
# Compare the raw key text — both should show the same line:
curl -s https://github.com/gesellix.keys
cat keys/public/diagnostic.pub
```
The key should appear verbatim in both outputs.
---
## Decrypting a received report (maintainer)
When a user sends you an `aftertouch-diagnostic-*.age` file, use the helper
script (no extra tools needed — only Go and the private key). Run from the
repository root directory:
```bash
# Decrypt and extract in one step:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age | tar xz
# Or decrypt to a .tar.gz first, then inspect:
go run scripts/decrypt-diagnostic.go aftertouch-diagnostic-<timestamp>.age > report.tar.gz
tar xzf report.tar.gz
# → diagnostic.json
# → datastore/accounts/{id}/devices/{id}/Presets.xml (and Sources.xml, Recents.xml, …)
```
The script uses only the `filippo.io/age` Go module — no separate `age` CLI
installation required.
---
## User workflow
1. Open the AfterTouch admin UI and go to the **Health** tab.
2. Click **Download diagnostic report**.
3. The browser downloads `aftertouch-diagnostic-<timestamp>.age`.
4. Attach the file to the GitHub issue or send it via a direct channel.
The file is opaque binary — the user cannot read it. All they see is that the
report was generated and downloaded.
---
## Key rotation
If the private key is compromised or lost:
1. Run `scripts/setup-diagnostic-key.sh` (delete the old `keys/private/diagnostic` first).
2. Add the new public key to GitHub and remove the old one.
3. Update `DiagnosticPublicKey` in `encrypt.go`.
4. Commit and tag a new release.
Old reports encrypted with the previous key cannot be decrypted with the new key.
@@ -1,5 +1,8 @@
# Technical Proposal: External Service Provider Abstraction
---
title: "Technical Proposal: External Service Provider Abstraction"
sidebar:
exclude: true
---
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
## 1. Problem Statement
@@ -1,5 +1,8 @@
# Feature Development History
---
title: "Feature Development History"
sidebar:
exclude: true
---
This document tracks the detailed evolution of features and capabilities in the Bose SoundTouch API client library.
## Development Timeline
@@ -1,5 +1,8 @@
# Host:Port Parsing Feature
---
title: "Host:Port Parsing Feature"
sidebar:
exclude: true
---
This document describes the automatic host:port parsing functionality added to the SoundTouch CLI, which allows users to specify both host and port in a single `-host` flag.
## Overview
@@ -1,5 +1,8 @@
# Manual Network Discovery on macOS
---
title: "Manual Network Discovery on macOS"
sidebar:
exclude: true
---
This document provides comprehensive guidance for manually discovering network services and devices using built-in macOS tools and command-line utilities. This is particularly useful for troubleshooting network discovery issues or understanding what services are available on your local network.
## Overview
@@ -1,5 +1,8 @@
# Navigation and Station Management Guide
---
title: "Navigation and Station Management Guide"
sidebar:
exclude: true
---
## Overview
The Bose SoundTouch Go client provides comprehensive navigation and station management functionality that allows you to:
@@ -895,4 +898,4 @@ For additional help:
---
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](reference/PRESET-MANAGEMENT.md).*
*This guide covers the complete navigation and station management functionality. For preset management, see [PRESET-MANAGEMENT.md](../reference/PRESET-MANAGEMENT.md).*

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