293 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.8 258b9e7471 chore(cli): list speaker url-upnp in speaker help
The help text still listed only tts/url/beep/notify. Add the UPnP
AVTransport option (no app key, no DNS; http:// only, replaces source).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 21:33:45 +02:00
Tobias GesellchenandClaude Opus 4.8 a54204c1d0 feat(cli): play a URL via UPnP/AVTransport, no app-key or DNS (refs #517)
Adds a third way to push a clip to a speaker, surfaced by @dagrider in
#517: POST SetAVTransportURI + Play to the speaker's UPnP MediaRenderer
control endpoint (port 8091). Unlike /speaker play_info it needs no
app_key and no DNS interception, so it works on a plain LAN; the
trade-off is it switches the speaker to the UPNP source and replaces the
current playback (no duck-and-resume).

- pkg/client: SetAVTransportURI, AVTransportPlay, PlayURLViaUPnP (+ the
  :8091 control-URL derivation and SOAP plumbing), with tests.
- cmd/soundtouch-cli: `speaker url-upnp --url <url>`.
- docs: document the UPnP/AVTransport option under POST /speaker.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:58:19 +02:00
Tobias GesellchenandClaude Opus 4.8 1c6f4c9eb8 fix(release): build the tagged commit and stamp the real version (#525)
v0.114.0 binaries reported version 0.0.0 in the web UI. Two root causes,
both fixed here.

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

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

Changes:

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:33:48 +02:00
Tobias GesellchenandClaude Opus 4.8 2744708a9d chore(release): drop the transitional soundtouch-web alias
soundtouch-web was a transitional alias of soundtouch-player. Stop
building and publishing it everywhere, and refresh the release notes
while at it:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:15 +02:00
Tobias GesellchenandClaude Opus 4.8 841a9047e2 style(example-dlna-server): satisfy golangci-lint (nilerr, revive)
Two lint fixes on the DLNA test server, no behaviour change:

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 20:56:04 +02:00
Tobias GesellchenandClaude Opus 4.8 da723a24e5 test(service): update router snapshot for /library routes
TestPrintRoutes is a golden snapshot of the full chi route tree. The new
DLNA Music Library routes (device-scoped /library/{servers,browse,play} and
the global /providers/library/servers, plus the /app/library SPA deep link)
legitimately extend the tree, so refresh the snapshot. Diff is exactly the
seven new library routes mapping to the new handlers; no other routes change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 2e5e7a5763 fix(cli): library play uses native STORED_MUSIC (drop raw-URL modes)
Hardware testing (ST10 FW27.0.6 + FRITZ!Box 6490) showed the previous
raw-URL play modes do not play DLNA content: a raw stream URL sent as a
LOCAL_INTERNET_RADIO location is rejected by the speaker (APServer
"REJECT: TransportControl: Wrong Client", nothing plays). The native
mechanism is STORED_MUSIC: register the server, then select a ContentItem
carrying the media server's object ID as location.

`library play` now takes --source-account (<UDN>/0) and --location
(object ID from a browse), plus optional --name/--type/--art, and selects
a STORED_MUSIC ContentItem with type="track" via SelectContentItem (so the
type is set, which SelectStoredMusic does not do). It first checks /sources
for a READY STORED_MUSIC entry with that account and, if absent, prints a
ready-to-copy `account add-nas` hint and stops instead of failing opaquely.
The old --url/--mode raw-URL flags are removed.

Validated end to end: browse -> play -> now_playing source=STORED_MUSIC
status=PLAY_STATE. The speaker streams from the media server directly; no
AfterTouch proxy involved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 c4acc794b8 feat(cli,client): soundtouch-cli library command + ListMediaServers
Adds the CLI-first surface for the DLNA feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213), so the
discovery/browse/play plumbing can be exercised against a real media server
and speaker without the web build loop.

- soundtouch-cli library servers: app-side SSDP sweep
  (discovery.DiscoverMediaServers); --via-speaker queries the speaker's own
  /listMediaServers instead, for an A/B of the two views.
- soundtouch-cli library browse --udn <id> [--object --start --count]:
  dlna.Browse of a discovered server's ContentDirectory.
- soundtouch-cli library play --url <streamURL> --mode <...>: plays a track
  URL on a speaker; --mode selects the playback path (local-internet-radio,
  local-music, stored-music, content-item) so the best one can be found
  empirically on hardware.
- pkg/client.ListMediaServers() + models.ListMediaServersResponse for the
  speaker-native (Option 2) path; an empty <ListMediaServersResponse />
  parses to an empty slice.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 11b59d3911 test(dlna): add DLNA MediaServer test server (fixture + LAN example)
Adds a dependency-free Go DLNA/UPnP MediaServer used to develop and test
the upcoming "browse a DLNA server and play on a SoundTouch" feature
(https://github.com/gesellix/Bose-SoundTouch/discussions/213).

Two faces over one content core:
- pkg/dlna/dlnatest: an in-process server (httptest) serving rootDesc.xml
  and ContentDirectory Browse for an injectable content tree, for fast
  cross-platform unit tests with no Docker and no multicast.
- cmd/example-dlna-server: the same handlers behind a real http.Server
  plus an SSDP responder (answers M-SEARCH, periodic NOTIFY), so a real
  speaker on the LAN can discover it and fetch real (silent WAV) audio.

A Docker minidlna was unusable here: on macOS the container IP in the
DIDL <res> URL is unreachable from the LAN, and its SSDP never reaches the
speakers. A native Go server embeds the host LAN IP and is discoverable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 fa158bc498 feat(cli/setup): friendlier enable-ssh timeout + re-sync boseurls after XML migration (refs #471)
Two follow-ups from the #471 field reports on the BETA `setup enable-ssh`:

1. enable-ssh: when sshd (:22) does not come up within the wait window, this is
   no longer treated as a hard error. On some devices (e.g. the Wireless Link
   Adapter) the envswitch injection is accepted but sshd only starts after the
   speaker restarts. The command now prints a warning with power-cycle + retry
   guidance (and the exact ssh command), deliberately leaves the injected
   boseurls in place so a restart re-triggers the unlock, and exits cleanly
   instead of failing.

2. XML migration: re-apply the boseurls over telnet at the end of migrateViaXML
   so the runtime layer reported by `getpdo CurrentSystemConfiguration` matches
   the persisted SoundTouchSdkPrivateCfg.xml. After enable-ssh bootstraps SSH,
   that runtime layer still points at the placeholder (https://aftertouch.invalid),
   so the preflight cross-check keeps warning that margeServerUrl/swUpdateUrl
   differ between transports until a reboot. The re-apply reconciles it now.
   Best-effort: if telnet is unavailable (e.g. port 17000 was closed via
   --close-17000), a reboot still reconciles the layers, so it only logs a note
   and never fails the migration.

Tests cover the re-apply command and its best-effort (non-fatal) behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:36:21 +02:00
Tobias GesellchenandClaude Opus 4.8 6c8a50c049 feat(cli): opt-in hardening for setup enable-ssh (--close-17000, --authorized-key) (refs #471)
Adds the #471 "secure" steps as opt-in flags on `setup enable-ssh`, off by
default (per the decision that closing 17000 must be opt-in):

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:11:02 +02:00
Tobias GesellchenandClaude Opus 4.8 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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