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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
Ports app's commit b040c8a. Five new handlers + five new routes for
master/slave stereo-pair and multi-room management; the Zone.js
frontend was already shipped in the Preact swap.
HandleGetZone GET /api/zone/{id}
Returns zone info enriched with member names and role flags
(isMaster / isSlave / isStandalone) computed from the perspective
of the queried device. Each member carries IP, hwID, and friendly
name so the frontend can render readable rows.
HandleZoneAdd POST /api/zone/{id}/add/{slaveId}
Adds a slave to the zone where {id} is or becomes the master.
Standalone master gets a fresh ZoneRequest; existing zone is
extended via ToZoneRequest + AddMember.
HandleZoneRemove POST /api/zone/{id}/remove/{slaveId}
Removes a named slave from the master's existing zone.
HandleZoneDissolve POST /api/zone/{id}/dissolve
Issues a single-member ZoneRequest so the master goes standalone.
HandleZoneLeave POST /api/zone/{id}/leave
Slave-side leave: looks up the master via findIPByHwID using the
slave's current zone info, then dispatches RemoveMember against
the master's client (the speaker protocol requires the master to
own the SetZone call).
Translation notes:
- All handlers go through app.GetDevice(id) instead of direct
app.Devices[id] access — matches main's encapsulated-registry
refactor (post-base on main, see registry_test.go).
- findIPByHwID iterates via app.DeviceSnapshot() instead of ranging
over the raw map.
- pkg/client (GetZone/SetZone) and pkg/models (ZoneInfo/ZoneRequest/
Member/NewZoneRequest/AddMember/RemoveMember/IsStandalone/
ToZoneRequest) API surface confirmed unchanged from app's base —
verbatim function calls.
Risk recap (per the earlier audit): this was flagged medium-risk
because of pkg/client zone-API drift. Verified clean — all symbols
exist with the expected signatures on current main. The #252 stereo-
pair work that landed on main was in cmd/soundtouch-cli/cmd_group.go
(parallel POST to LEFT and RIGHT), which doesn't intersect with the
single-master SetZone pattern these handlers use.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... ./cmd/soundtouch-web/...
0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ports app's commit 3122c4e to the package layout. Two new handlers
and route registrations; the frontend was already shipped in the
Preact swap.
HandleDeviceRecents GET /api/device-recents/{id}
Returns the speaker's /recents list as APIResponse{Success,Data}.
Backs the Recents.js component (lazy-loaded list under the
device-detail view; hides itself when the device returns no
recents).
HandleDevicePlay POST /api/device-play/{id}
Generic content-item player. Decodes a {source,type,location,
sourceAccount,itemName,containerArt,isPresetable} JSON body into
a *models.ContentItem and runs Client.SelectContentItem. Used by
Recents.js to replay items the speaker reports, regardless of
source — TuneIn, Spotify, AUX, etc. Different from HandlePlayTuneIn
which is TuneIn-specific.
Translation note: app's bodies used app.Devices[id] directly; main's
registry is encapsulated behind GetDevice/AddDevice/TouchDevice (see
the post-base refactor that introduced registry_test.go), so this
commit uses app.GetDevice(id) instead. Same lookup, just through the
maintained API.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./pkg/service/soundtouchweb/... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical relocation only — zero semantic change. Sets up the package
layout that the future Preact-UI rewrite (branch `app`) wants, while
preserving every line of main's current logic. Subsequent commits will
land the additive parts (frontend rewrite, recents, zones, bass control)
on top of this clean base.
Moves (`git mv`, content unchanged except package decl):
cmd/soundtouch-web/handlers/handlers.go → pkg/service/soundtouchweb/handler.go
cmd/soundtouch-web/handlers/handlers_test.go → pkg/service/soundtouchweb/handler_test.go
cmd/soundtouch-web/handlers/websocket.go → pkg/service/soundtouchweb/websocket.go
cmd/soundtouch-web/handlers/registry_test.go → pkg/service/soundtouchweb/registry_test.go
cmd/soundtouch-web/webtypes/types.go → pkg/service/soundtouchweb/webtypes/types.go
cmd/soundtouch-web/webtypes/types_test.go → pkg/service/soundtouchweb/webtypes/types_test.go
cmd/soundtouch-web/webtypes/status_test.go → pkg/service/soundtouchweb/webtypes/status_test.go
cmd/soundtouch-web/static/img/tunein-{dark,mono}.svg → pkg/service/soundtouchweb/static/img/
Adjustments:
- `package handlers` → `package soundtouchweb` in the 4 moved handler-tier
files (plus their package-doc comments).
- Import paths rewritten in cmd/soundtouch-web/{main.go,spa_test.go} and
in the moved files themselves: cmd/soundtouch-web/{handlers,webtypes}
→ pkg/service/soundtouchweb/{,webtypes}.
- `handlers.` selector renamed to `soundtouchweb.` in the callers.
- `.golangci.yml` errcheck waiver extended from `cmd/.*\.go` to also
cover `pkg/service/soundtouchweb/.*\.go`. Same code that the
cmd-tier waiver applied to; same waiver follows it. Documented as
a carry-over with the intent to tighten in a follow-up review.
Not changed:
- `cmd/soundtouch-web/main.go` keeps the `//go:embed static` pointing at
the still-vanilla `cmd/soundtouch-web/static/`. The frontend rewrite
(Preact UI) lands in a later commit; this one is mechanical.
- `cmd/soundtouch-web/resolve_bind_addr_test.go` stays put — it tests
main.go-local flag plumbing.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails, untracked-file issue, unrelated).
golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>