Compare commits

...
8 Commits
Author SHA1 Message Date
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
37 changed files with 1126 additions and 291 deletions
+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 (
+5
View File
@@ -385,6 +385,11 @@ func main() {
Name: "artwork",
Usage: "Artwork URL",
},
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service HTTPS URL (e.g. https://soundtouch.local). Required for LOCAL_INTERNET_RADIO: the speaker's BMX module calls GET on the preset location and expects an Orion JSON response, not raw audio. When provided, the stream URL is automatically wrapped in the Orion station endpoint.",
EnvVars: []string{"SOUNDTOUCH_SERVICE_URL"},
},
},
Before: RequireHost,
},
+7
View File
@@ -1097,6 +1097,11 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
// Speakers send DELETE /group/ (no group ID, trailing slash) during
// stereo-pair teardown; master and slave use their own account IDs
// so each deletes its own copy.
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
})
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
@@ -1144,6 +1149,8 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/group/", server.HandleMargeAddGroup)
r.Post("/group/{groupId}", server.HandleMargeModifyGroup)
r.Delete("/group/{groupId}", server.HandleMargeDeleteGroup)
r.Delete("/group", server.HandleMargeDeleteAccountGroups)
r.Delete("/group/", server.HandleMargeDeleteAccountGroups)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
+4
View File
@@ -1,6 +1,8 @@
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 handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /accounts/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-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
@@ -12,6 +14,8 @@ DELETE /setup/interactions/sessions/{session} handlers.(
DELETE /setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
DELETE /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
DELETE /streaming/account/{account}/group handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/ handlers.(*Server).HandleMargeDeleteAccountGroups-fm
DELETE /streaming/account/{account}/group/{groupId} handlers.(*Server).HandleMargeDeleteGroup-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
+7
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"runtime/debug"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
@@ -75,6 +76,11 @@ 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"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -108,6 +114,7 @@ func main() {
webApp.Commit = commit
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
@@ -40,7 +40,7 @@ starts on boot.
To install a specific version:
```bash
sudo bash install.sh v0.93.1
sudo bash install.sh v0.98.0
```
Check that the service is running:
@@ -242,7 +242,7 @@ curl -s http://192.0.2.1:8090/presets
```bash
sudo bash install.sh # updates to latest release
sudo bash install.sh v0.93.1 # updates to a specific version
sudo bash install.sh v0.98.0 # updates to a specific version
```
The installer stops the service, downloads the new binary, and restarts
@@ -88,10 +88,10 @@ To target a specific version instead of the default:
```bash
# Via environment variable (works with pipe-to-sh)
VERSION=0.92.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
VERSION=0.98.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# Via command-line flag (pass args after sh -s --)
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.92.0
curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.98.0
```
Verify the installed version:
@@ -100,7 +100,7 @@ Verify the installed version:
wget -qO- http://localhost:8000/health
```
The JSON response should include `"version":"v0.93.1"` (or whichever
The JSON response should include `"version":"v0.98.0"` (or whichever
version you installed).
---
@@ -188,13 +188,13 @@ next reboot — which is fine for a one-time setup run):
cd /tmp
curl -L --fail -o soundtouch-cli \
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.93.1/soundtouch-cli-v0.93.1-linux-armv7
https://github.com/gesellix/Bose-SoundTouch/releases/download/v0.98.0/soundtouch-cli-v0.98.0-linux-armv7
chmod +x soundtouch-cli
/tmp/soundtouch-cli --version
```
Replace `v0.93.1` with the version you installed.
Replace `v0.98.0` with the version you installed.
---
+2 -2
View File
@@ -36,7 +36,7 @@ You can customize the installation using environment variables:
```bash
sudo \
VERSION=v0.93.1 \
VERSION=v0.98.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
@@ -48,7 +48,7 @@ sudo \
To update the service to a specific version, run the installer with the version as an argument:
```bash
sudo bash install.sh v0.93.1
sudo bash install.sh v0.98.0
```
The installer will automatically fetch the latest version of itself for that release and then update the service binary and restart it.
+1 -1
View File
@@ -2,7 +2,7 @@ module navigation-station-demo
go 1.26.3
require github.com/gesellix/bose-soundtouch v0.93.1
require github.com/gesellix/bose-soundtouch v0.98.0
require github.com/gorilla/websocket v1.5.3 // indirect
+17 -13
View File
@@ -1,3 +1,4 @@
// Package main demonstrates content navigation and station management with SoundTouch devices.
package main
import (
@@ -41,12 +42,14 @@ func main() {
func demonstrateNavigationAndStations(c *client.Client) error {
// 1. Browse TuneIn content
fmt.Println("📻 Step 1: Browsing TuneIn stations...")
if err := browseTuneInStations(c); err != nil {
return fmt.Errorf("failed to browse TuneIn: %w", err)
}
// 2. Search for specific content
fmt.Println("\n🔍 Step 2: Searching for jazz stations...")
searchResults, err := searchForJazzStations(c)
if err != nil {
return fmt.Errorf("failed to search stations: %w", err)
@@ -54,6 +57,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 3. Add and play a station
fmt.Println("\n Step 3: Adding and playing a station...")
if err := addAndPlayStation(c, searchResults); err != nil {
fmt.Printf("⚠️ Could not add station: %v\n", err)
// Continue with demo even if this fails
@@ -61,6 +65,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 4. Demonstrate Pandora search (if account available)
fmt.Println("\n🎵 Step 4: Demonstrating Pandora search...")
if err := demonstratePandoraSearch(c); err != nil {
fmt.Printf("⚠️ Pandora search not available: %v\n", err)
// Continue with demo
@@ -68,6 +73,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 5. Browse stored music (if available)
fmt.Println("\n💿 Step 5: Browsing stored music...")
if err := browseStoredMusic(c); err != nil {
fmt.Printf("⚠️ Stored music not available: %v\n", err)
// Continue with demo
@@ -75,6 +81,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
// 6. Search Spotify content (if account available)
fmt.Println("\n🎧 Step 6: Demonstrating Spotify search...")
if err := demonstrateSpotifySearch(c); err != nil {
fmt.Printf("⚠️ Spotify search not available: %v\n", err)
// Continue with demo
@@ -95,8 +102,10 @@ func browseTuneInStations(c *client.Client) error {
if len(response.Items) > 0 {
fmt.Printf(" 🎵 Sample stations:\n")
for i, item := range response.Items[:min(5, len(response.Items))] {
fmt.Printf(" %d. %s\n", i+1, item.GetDisplayName())
if item.IsPlayable() {
fmt.Printf(" ▶️ Playable\n")
} else if item.IsDirectory() {
@@ -125,13 +134,16 @@ func searchForJazzStations(c *client.Client) (*models.SearchStationResponse, err
if len(songs) > 0 {
fmt.Printf(" 🎵 Songs (%d): %s\n", len(songs), songs[0].GetDisplayName())
}
if len(artists) > 0 {
fmt.Printf(" 🎤 Artists (%d): %s\n", len(artists), artists[0].GetDisplayName())
}
if len(stations) > 0 {
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
for i, station := range stations[:min(3, len(stations))] {
fmt.Printf(" %d. %s (Token: %s)\n", i+1, station.GetDisplayName(), station.Token)
for i := range stations[:min(3, len(stations))] {
fmt.Printf(" %d. %s (Token: %s)\n", i+1, stations[i].GetDisplayName(), stations[i].Token)
}
}
@@ -176,7 +188,7 @@ func addAndPlayStation(c *client.Client, searchResults *models.SearchStationResp
return nil
}
func demonstratePandoraSearch(c *client.Client) error {
func demonstratePandoraSearch(_ *client.Client) error {
// Note: This would require a valid Pandora account
// For demo purposes, we'll show how it would work
fmt.Printf(" 🎵 Pandora search requires a valid source account\n")
@@ -190,7 +202,7 @@ func demonstratePandoraSearch(c *client.Client) error {
return nil
}
func browseStoredMusic(c *client.Client) error {
func browseStoredMusic(_ *client.Client) error {
// Note: This would require a valid device ID for stored music
fmt.Printf(" 💿 Stored music browsing requires device ID\n")
fmt.Printf(" 💡 Example usage:\n")
@@ -204,7 +216,7 @@ func browseStoredMusic(c *client.Client) error {
return nil
}
func demonstrateSpotifySearch(c *client.Client) error {
func demonstrateSpotifySearch(_ *client.Client) error {
// Note: This would require a valid Spotify account
fmt.Printf(" 🎧 Spotify search requires a valid source account\n")
fmt.Printf(" 💡 Example usage:\n")
@@ -218,14 +230,6 @@ func demonstrateSpotifySearch(c *client.Client) error {
return nil
}
// Helper function to get minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
func printUsage() {
fmt.Println("🎵 SoundTouch Navigation & Station Management Demo")
fmt.Println()
+1 -1
View File
@@ -2,7 +2,7 @@ module preset-management-example
go 1.26.3
require github.com/gesellix/bose-soundtouch v0.93.1
require github.com/gesellix/bose-soundtouch v0.98.0
require github.com/gorilla/websocket v1.5.3 // indirect
+27
View File
@@ -4,11 +4,38 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"net/url"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// BuildOrionLocation wraps a raw stream URL in the AfterTouch Orion station
// endpoint that the speaker's BMX module expects when playing LOCAL_INTERNET_RADIO
// content. The speaker calls GET on the stored location expecting a
// BmxPlaybackResponse JSON — not raw audio bytes.
func BuildOrionLocation(serviceURL, name, imageURL, streamURL string) string {
payload := struct {
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
StreamURL string `json:"streamUrl"`
}{
Name: name,
ImageURL: imageURL,
StreamURL: streamURL,
}
data, err := json.Marshal(payload)
if err != nil {
return ""
}
encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data))
return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded
}
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
streamList := []models.Stream{
+31
View File
@@ -2816,6 +2816,37 @@ func (ds *DataStore) DeleteGroup(account, groupID string) error {
return err
}
// DeleteAllGroupsForAccount removes every Group_*.xml file stored under
// account. Speakers send DELETE /streaming/account/{id}/group/ (no group
// ID) during stereo-pair teardown; since master and slave may live in
// different accounts each speaker deletes its own copy. Returns nil if no
// group files are found — idempotent by design.
func (ds *DataStore) DeleteAllGroupsForAccount(account string) error {
ds.fileMutex.Lock()
defer ds.fileMutex.Unlock()
dir := ds.AccountDevicesDir(account)
entries, err := ds.rootReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil // nothing to delete
}
return err
}
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), "Group_") || !strings.HasSuffix(e.Name(), ".xml") {
continue
}
_ = ds.rootRemove(filepath.Join(dir, e.Name()))
}
return nil
}
// SaveTuneInFavorite records a TuneIn station as favorited by creating a marker file.
// File presence indicates the station is a favorite; no content is stored.
func (ds *DataStore) SaveTuneInFavorite(stationID string) error {
+48 -9
View File
@@ -52,21 +52,33 @@ type Options struct {
ReleaseDuration float64 // seconds of fade-out per chirp. Default 0.060.
Peak float64 // final-mix headroom; 0 < Peak <= 1.0. Default 0.85.
// Repeat is the total number of times the complete ding is played.
// Speakers need a moment to start buffering after receiving a
// ContentItem, so the first repetition may be missed; later ones
// will be heard. Default 3.
Repeat int
// RepeatGapDuration is the silence inserted between successive
// repetitions, in seconds. Default 0.40.
RepeatGapDuration float64
}
// DefaultOptions returns the canonical option set used by the
// runtime handler when no overrides are supplied.
func DefaultOptions() Options {
return Options{
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
SampleRate: 22050,
PitchHigh: 880.00,
PitchMid: 659.2551,
PitchLow: 440.00,
ChirpDuration: 0.25,
GapDuration: 0.10,
AttackDuration: 0.020,
ReleaseDuration: 0.060,
Peak: 0.85,
Repeat: 3,
RepeatGapDuration: 0.40,
}
}
@@ -115,6 +127,14 @@ func (o Options) WithDefaults() Options {
o.Peak = d.Peak
}
if o.Repeat <= 0 {
o.Repeat = d.Repeat
}
if o.RepeatGapDuration <= 0 {
o.RepeatGapDuration = d.RepeatGapDuration
}
return o
}
@@ -147,6 +167,25 @@ func Render(opts Options) []byte {
renderChirp(left, right, 0, chirpN, attackN, releaseN, voicesS, opts.SampleRate)
renderChirp(left, right, chirpN+gapN, chirpN, attackN, releaseN, voicesT, opts.SampleRate)
// Repeat: append silence + a copy of the base audio for each
// additional repetition. Speakers need a moment to start buffering
// after receiving a ContentItem; repeating ensures at least one
// instance is audible even if the first is missed.
if opts.Repeat > 1 {
repeatGapN := int(math.Round(float64(opts.SampleRate) * opts.RepeatGapDuration))
baseLeft := append([]float64{}, left...)
baseRight := append([]float64{}, right...)
silence := make([]float64, repeatGapN)
for i := 1; i < opts.Repeat; i++ {
left = append(left, silence...)
right = append(right, silence...)
left = append(left, baseLeft...)
right = append(right, baseRight...)
}
}
normalise(left, right, opts.Peak)
var buf bytes.Buffer
+14 -5
View File
@@ -45,13 +45,13 @@ func TestRender_ProducesWAVHeader(t *testing.T) {
}
}
func TestRender_DefaultSizeApproximately52KB(t *testing.T) {
func TestRender_DefaultSizeApproximately229KB(t *testing.T) {
data := Render(DefaultOptions())
// Default: 22050 Hz * 2 channels * 2 bytes * 0.6 s = 52920 data
// + ~44 byte header.
const wantData = 22050 * 2 * 2 * 60 / 100 // 0.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+200 {
// Default: 3 repetitions of 0.6 s + 2 gaps of 0.4 s = 2.6 s total.
// 22050 Hz * 2 ch * 2 bytes * 2.6 s ≈ 229320 data bytes + 44 byte header.
const wantData = 22050 * 2 * 2 * 260 / 100 // 2.6 seconds, integer math
if got := len(data); got < wantData || got > wantData+500 {
t.Errorf("expected ~%d bytes, got %d", wantData, got)
}
}
@@ -112,6 +112,15 @@ func TestRender_HugeSampleRateDoesNotTruncateOrPanic(t *testing.T) {
}
}
func TestRender_RepeatProducesLongerAudio(t *testing.T) {
once := Render(Options{Repeat: 1}.WithDefaults())
thrice := Render(Options{Repeat: 3}.WithDefaults())
if len(thrice) <= len(once) {
t.Errorf("expected Repeat:3 to produce more bytes than Repeat:1: %d vs %d", len(thrice), len(once))
}
}
func TestWithDefaults_FillsZeroFields(t *testing.T) {
got := Options{PitchHigh: 1000}.WithDefaults()
if got.PitchHigh != 1000 {
+27
View File
@@ -35,6 +35,8 @@ var dingDefaultCache struct {
// release-ms int milliseconds; default 60
// sample-rate Hz, int; default 22050
// peak 0..1 float; default 0.85
// repeat int 1..10; default 3
// repeat-gap-ms int milliseconds; default 400
//
// The default option set is rendered once via sync.Once and the
// resulting bytes are reused across subsequent default requests —
@@ -124,6 +126,16 @@ func parseDingOptions(r *http.Request) (ding.Options, bool) {
touched = true
}
if v, ok := repeatParam(q.Get("repeat")); ok {
opts.Repeat = v
touched = true
}
if v, ok := millisecondsParam(q.Get("repeat-gap-ms")); ok {
opts.RepeatGapDuration = v
touched = true
}
if !touched {
return ding.DefaultOptions(), true
}
@@ -167,6 +179,21 @@ const (
dingMaxSampleRate = 192000
)
// repeatParam parses the "repeat" query knob (integer, 110).
// Values outside that range silently fall back to the default.
func repeatParam(raw string) (int, bool) {
if raw == "" {
return 0, false
}
v, err := strconv.Atoi(raw)
if err != nil || v < 1 || v > 10 {
return 0, false
}
return v, true
}
func sampleRateParam(raw string) (int, bool) {
if raw == "" {
return 0, false
@@ -27,6 +27,10 @@ type healthFixRequest struct {
type healthFixResponse struct {
OK bool `json:"ok"`
Message string `json:"message,omitempty"`
// Refresh tells the UI whether to re-fetch health after this fix.
// false for persistent affordances (e.g. play_ding) that don't
// change any check state, so no "Loading…" flash occurs.
Refresh bool `json:"refresh"`
}
// HandleHealthChecks runs every registered health check and
@@ -73,7 +77,7 @@ func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
return
}
msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
msg, refresh, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
if err != nil {
if errors.Is(err, health.ErrFixNotFound) {
writeJSONError(w, http.StatusNotFound, err.Error())
@@ -87,7 +91,7 @@ func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil {
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg, Refresh: refresh}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
+23 -1
View File
@@ -901,7 +901,7 @@ func (s *Server) HandleMargeModifyGroup(w http.ResponseWriter, r *http.Request)
_, _ = w.Write(data)
}
// HandleMargeDeleteGroup removes a stereo group.
// HandleMargeDeleteGroup removes a stereo group identified by {groupId}.
func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
groupID := chi.URLParam(r, "groupId")
@@ -921,6 +921,28 @@ func (s *Server) HandleMargeDeleteGroup(w http.ResponseWriter, r *http.Request)
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
}
// HandleMargeDeleteAccountGroups removes all stereo groups stored for an
// account. Speakers send DELETE /streaming/account/{id}/group/ (trailing
// slash, no group ID) during stereo-pair teardown. Master and slave often
// live in different accounts, so each speaker deletes its own copy here.
func (s *Server) HandleMargeDeleteAccountGroups(w http.ResponseWriter, r *http.Request) {
account := chi.URLParam(r, "account")
if !validatePathID(account) {
http.Error(w, "Invalid account ID", http.StatusBadRequest)
return
}
if err := s.ds.DeleteAllGroupsForAccount(account); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(constants.XMLHeader + `<status>Group deleted successfully</status>`))
}
// HandleMusicProviderIsEligible returns the music provider eligibility.
func (s *Server) HandleMusicProviderIsEligible(w http.ResponseWriter, _ *http.Request) {
// For now, we return false as seen in the interaction sample.
+4 -2
View File
@@ -4368,8 +4368,10 @@ async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
status.textContent = data.message || "Done.";
status.style.color = "#2e7d32";
}
// Refresh to drop the resolved finding.
setTimeout(fetchHealth, 400);
// Re-fetch health so resolved findings disappear from the list.
// Skipped when the server signals refresh:false (persistent
// affordances like play_ding that don't change check state).
if (data.refresh !== false) setTimeout(fetchHealth, 400);
} catch (e) {
if (status) {
status.textContent = `Failed: ${e.message || e}`;
+2 -2
View File
@@ -77,7 +77,7 @@ func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) {
r := NewRegistry()
RegisterSourcesXMLPresent(r, ds)
msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
msg, _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
Account: account,
Device: device,
})
@@ -143,7 +143,7 @@ func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) {
r := NewRegistry()
RegisterSourcesXMLPresent(r, ds)
if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
if _, _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
t.Errorf("expected error for empty target, got nil")
}
}
+35 -9
View File
@@ -3,6 +3,7 @@ package health
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
@@ -28,6 +29,23 @@ const FixIDPlayDing = "play_ding"
// pkg/service/handlers (see static/media embed).
const DingMediaPath = "/media/aftertouch-ding.wav"
// DingCustomPath is the AfterTouch custom-playback prefix. The speaker
// fetches this URL, AfterTouch responds with a BMX JSON payload, and the
// speaker plays via LOCAL_INTERNET_RADIO — avoiding the INTERNET_RADIO
// FLAC-parser path that causes UNKNOWN_SOURCE_ERROR (1005) on some firmware
// versions (see issue #345).
const DingCustomPath = "/custom/v1/playback/"
// dingCustomURL builds the LOCAL_INTERNET_RADIO proxy URL for the ding WAV.
// The WAV URL is base64url-encoded into the path; the name query param sets
// the display name on the speaker.
func dingCustomURL(serverURL string) string {
mediaURL := serverURL + DingMediaPath
encoded := base64.URLEncoding.EncodeToString([]byte(mediaURL))
return serverURL + DingCustomPath + encoded + "?name=AfterTouch+ding"
}
// RegisterTestPlaybackCheck registers the playback_test check and
// its play_ding quick fix. serverURLFn returns the externally
// reachable URL of this service — the speaker fetches the audio
@@ -42,7 +60,10 @@ func RegisterTestPlaybackCheck(r *Registry, ds *datastore.DataStore, serverURLFn
},
})
r.RegisterFix(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) {
// play_ding is a persistent operator affordance, not a resolvable
// finding — success doesn't change any check state, so the UI
// should not re-fetch health afterwards (no "Loading…" flash).
r.RegisterFixNoRefresh(CheckIDTestPlayback, FixIDPlayDing, func(target Target) (string, error) {
return playDingOnDevice(ds, serverURLFn(), target)
})
}
@@ -80,7 +101,7 @@ func runTestPlaybackCheck(ds *datastore.DataStore, serverURL string) []Finding {
Severity: SeverityInfo,
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
Message: fmt.Sprintf("Play the AfterTouch ding on %s.", displayName(dev.Name, dev.DeviceID)),
Details: fmt.Sprintf("Pushes %s%s to the speaker via a custom-radio ContentItem. Confirms migration is healthy end-to-end without depending on TuneIn or any external service.", serverURL, DingMediaPath),
Details: fmt.Sprintf("Pushes the ding WAV to the speaker via LOCAL_INTERNET_RADIO (custom-playback proxy at %s%s). Confirms migration is healthy end-to-end without depending on TuneIn or any external service.", serverURL, DingCustomPath),
QuickFixes: []QuickFix{{
ID: FixIDPlayDing,
Label: "Play ding",
@@ -114,8 +135,8 @@ func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target)
return "", fmt.Errorf("device %s has no IP address recorded", target.Device)
}
mediaURL := serverURL + DingMediaPath
contentItem := buildDingContentItem(mediaURL)
customURL := dingCustomURL(serverURL)
contentItem := buildDingContentItem(customURL)
selectURL := fmt.Sprintf("http://%s:8090/select", dev.IPAddress)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
@@ -143,18 +164,23 @@ func playDingOnDevice(ds *datastore.DataStore, serverURL string, target Target)
return fmt.Sprintf("Pushed ding URL to %s. You should hear it within a second.", displayName(dev.Name, target.Device)), nil
}
func buildDingContentItem(mediaURL string) string {
escaped := xmlAttrEscape(mediaURL)
// buildDingContentItem returns the XML ContentItem that pushes the ding to a
// speaker. Uses LOCAL_INTERNET_RADIO with the AfterTouch custom-playback
// proxy URL so the speaker fetches a BMX JSON response and plays via the
// LOCAL_INTERNET_RADIO code path — avoiding the INTERNET_RADIO FLAC-parser
// issue that causes UNKNOWN_SOURCE_ERROR (1005) on some firmware versions
// (issue #345).
func buildDingContentItem(customURL string) string {
escaped := xmlAttrEscape(customURL)
return fmt.Sprintf(
`<ContentItem source="INTERNET_RADIO" type="stationurl" location="%s" sourceAccount="" isPresetable="false"><itemName>AfterTouch ding</itemName></ContentItem>`,
`<ContentItem source="LOCAL_INTERNET_RADIO" type="stationurl" location="%s" sourceAccount="" isPresetable="true"><itemName>AfterTouch ding</itemName></ContentItem>`,
escaped,
)
}
func dingCurlCommand(speakerIP, serverURL string) string {
mediaURL := serverURL + DingMediaPath
body := buildDingContentItem(mediaURL)
body := buildDingContentItem(dingCustomURL(serverURL))
return fmt.Sprintf(
"curl -sS -X POST 'http://%s:8090/select' -H 'Content-Type: application/xml' -d '%s'",
@@ -118,24 +118,31 @@ func TestPlayDing_PostsContentItemToSelectEndpoint(t *testing.T) {
// reuse it directly with httptest. Test the building blocks
// (ContentItem rendering + the curl-command form) here, and
// leave the full POST plumbing for a manual smoke test.
mediaURL := "http://aftertouch.local" + DingMediaPath
contentItem := buildDingContentItem(mediaURL)
const serverBase = "http://aftertouch.local"
customURL := dingCustomURL(serverBase)
contentItem := buildDingContentItem(customURL)
if !strings.Contains(contentItem, "source=\"INTERNET_RADIO\"") {
t.Errorf("ContentItem missing INTERNET_RADIO source, got %q", contentItem)
if !strings.Contains(contentItem, "source=\"LOCAL_INTERNET_RADIO\"") {
t.Errorf("ContentItem missing LOCAL_INTERNET_RADIO source, got %q", contentItem)
}
if !strings.Contains(contentItem, mediaURL) {
t.Errorf("ContentItem missing media URL, got %q", contentItem)
if !strings.Contains(contentItem, DingCustomPath) {
t.Errorf("ContentItem missing custom-playback path, got %q", contentItem)
}
cmd := dingCurlCommand("192.0.2.10", "http://aftertouch.local")
// The WAV URL is base64-encoded inside the custom URL — verify the
// custom URL itself is present in the ContentItem.
if !strings.Contains(contentItem, customURL) {
t.Errorf("ContentItem missing custom URL, got %q", contentItem)
}
cmd := dingCurlCommand("192.0.2.10", serverBase)
if !strings.Contains(cmd, "192.0.2.10:8090/select") {
t.Errorf("curl command should target speaker /select, got %q", cmd)
}
if !strings.Contains(cmd, "/media/aftertouch-ding.wav") {
t.Errorf("curl command should include the ding URL, got %q", cmd)
if !strings.Contains(cmd, DingCustomPath) {
t.Errorf("curl command should include the custom-playback path, got %q", cmd)
}
}
+38 -12
View File
@@ -114,18 +114,29 @@ type CheckResult struct {
// registered under the (checkID, fixID) pair.
var ErrFixNotFound = errors.New("quick fix not registered")
// fixEntry pairs a FixFunc with its refresh policy. refresh=true
// means the UI should re-run fetchHealth after the fix succeeds so
// resolved findings disappear from the list. refresh=false is used
// for persistent affordances (e.g. play_ding) that never change check
// state — no re-render is needed and the brief "Loading…" flash is
// avoided.
type fixEntry struct {
fn FixFunc
refresh bool
}
// Registry owns the set of checks and fixes for one Server
// instance. The default zero value is not usable; construct via
// NewRegistry.
type Registry struct {
mu sync.RWMutex
checks []Check
fixes map[string]FixFunc // key: "<checkID>/<fixID>"
fixes map[string]fixEntry // key: "<checkID>/<fixID>"
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry {
return &Registry{fixes: map[string]FixFunc{}}
return &Registry{fixes: map[string]fixEntry{}}
}
// Register adds a check to the registry. Duplicate IDs replace
@@ -147,12 +158,25 @@ func (r *Registry) Register(c Check) {
// RegisterFix associates a FixFunc with the given (checkID, fixID)
// pair. A QuickFix with that ID can be advertised by any Finding
// emitted by the matching check.
// emitted by the matching check. After a successful run the UI will
// re-fetch health so resolved findings disappear from the list.
func (r *Registry) RegisterFix(checkID, fixID string, fn FixFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.fixes[fixKey(checkID, fixID)] = fn
r.fixes[fixKey(checkID, fixID)] = fixEntry{fn: fn, refresh: true}
}
// RegisterFixNoRefresh is like RegisterFix but signals the UI that
// re-fetching health after the fix runs is unnecessary. Use this for
// persistent operator affordances (e.g. play_ding) whose success
// doesn't change any check state — skipping the re-fetch avoids a
// distracting "Loading…" flash with no benefit.
func (r *Registry) RegisterFixNoRefresh(checkID, fixID string, fn FixFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.fixes[fixKey(checkID, fixID)] = fixEntry{fn: fn, refresh: false}
}
// RunAll executes every registered check and returns the results
@@ -183,20 +207,22 @@ func (r *Registry) RunAll() []CheckResult {
return out
}
// RunFix dispatches to the FixFunc registered for (checkID,
// fixID). The returned string is forwarded as the user-facing
// success message. ErrFixNotFound is returned when no fix is
// registered.
func (r *Registry) RunFix(checkID, fixID string, target Target) (string, error) {
// RunFix dispatches to the FixFunc registered for (checkID, fixID).
// Returns the user-facing success message, whether the UI should
// re-fetch health afterwards, and any execution error.
// ErrFixNotFound is returned when no fix is registered.
func (r *Registry) RunFix(checkID, fixID string, target Target) (string, bool, error) {
r.mu.RLock()
fn, ok := r.fixes[fixKey(checkID, fixID)]
entry, ok := r.fixes[fixKey(checkID, fixID)]
r.mu.RUnlock()
if !ok {
return "", fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID)
return "", false, fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID)
}
return fn(target)
msg, err := entry.fn(target)
return msg, entry.refresh, err
}
func fixKey(checkID, fixID string) string {
+6 -2
View File
@@ -58,7 +58,7 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) {
return "applied", nil
})
msg, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"})
msg, refresh, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -67,6 +67,10 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) {
t.Errorf("unexpected message: %q", msg)
}
if !refresh {
t.Errorf("expected refresh=true for a fix registered via RegisterFix")
}
if captured.Account != "A" || captured.Device != "D" {
t.Errorf("target not propagated to fix: %+v", captured)
}
@@ -75,7 +79,7 @@ func TestRegistry_RunFix_Dispatch(t *testing.T) {
func TestRegistry_RunFix_NotFound(t *testing.T) {
r := NewRegistry()
_, err := r.RunFix("nope", "also-nope", Target{})
_, _, err := r.RunFix("nope", "also-nope", Target{})
if !errors.Is(err, ErrFixNotFound) {
t.Errorf("expected ErrFixNotFound, got %v", err)
}
+90 -4
View File
@@ -35,10 +35,11 @@ type WebApp struct {
WSClients map[*websocket.Conn]bool
WSMutex sync.RWMutex
Version string
Commit string
Date string
RepoURL string
Version string
Commit string
Date string
RepoURL string
ServiceURL string
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
@@ -1084,6 +1085,81 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
}
}
// HandlePlayURL plays a custom stream URL on a device. When ServiceURL is
// configured the stream is wrapped in the Orion location format so the
// speaker's BMX module receives JSON instead of raw audio bytes. This also
// ensures that the ★ preset save flow stores a working location.
func (app *WebApp) HandlePlayURL(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
var req struct {
URL string `json:"url"`
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
ServiceURL string `json:"serviceUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.URL == "" {
app.sendError(w, "url is required", http.StatusBadRequest)
return
}
// Server-side --service-url wins; fall back to client-supplied value.
serviceURL := app.ServiceURL
if serviceURL == "" {
serviceURL = strings.TrimRight(req.ServiceURL, "/")
}
if serviceURL == "" {
app.sendError(w,
"AfterTouch service URL is required for LOCAL_INTERNET_RADIO playback. "+
"Start soundtouch-web with --service-url <https://your-aftertouch-host> or enter it in the Play URL settings.",
http.StatusBadRequest)
return
}
location := bmxpkg.BuildOrionLocation(serviceURL, req.Name, req.ImageURL, req.URL)
contentItem := &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Location: location,
ItemName: req.Name,
IsPresetable: true,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": "Playing " + req.Name},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIVersion returns the current version of the application.
func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -1095,12 +1171,22 @@ func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) {
"repo_url": app.RepoURL,
"release_url": app.RepoURL + "/releases/tag/" + app.Version,
"commit_url": app.RepoURL + "/commit/" + app.Commit,
"service_url": app.ServiceURL,
}
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: versionInfo}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleHealth returns a minimal liveness response.
func (app *WebApp) HandleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok", "version": app.Version}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleRadioBrowserSearch handles RadioBrowser search requests.
func (app *WebApp) HandleRadioBrowserSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
+7
View File
@@ -22,6 +22,9 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
// WebSocket endpoint
r.Get("/ws", app.HandleWebSocket)
// Health / liveness
r.Get("/health", app.HandleHealth)
// API endpoints
r.Get("/api/devices", app.HandleAPIDevices)
r.Get("/api/device/{id}", app.HandleAPIDevice)
@@ -73,12 +76,16 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
r.Get("/api/radiobrowser/search", app.HandleRadioBrowserSearch)
r.Post("/api/radiobrowser/play/{id}", app.HandlePlayRadioBrowser)
// Custom URL playback
r.Post("/api/play-url/{id}", app.HandlePlayURL)
// SPA routes — serve index.html for client-side routing
r.Get("/", app.serveIndex)
r.Get("/devices", app.serveIndex)
r.Get("/device/*", app.serveIndex)
r.Get("/tunein", app.serveIndex)
r.Get("/radiobrowser", app.serveIndex)
r.Get("/playurl", app.serveIndex)
}
func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) {
+6 -3
View File
@@ -197,7 +197,8 @@ img { display: block; max-width: 100%; }
.nav-links a.active .nav-tunein-icon,
.nav-links a.active .nav-rb-icon,
.nav-links a.active .nav-device-icon {
.nav-links a.active .nav-device-icon,
.nav-links a.active .nav-url-icon {
filter: none;
opacity: 1;
}
@@ -210,12 +211,13 @@ img { display: block; max-width: 100%; }
}
.nav-links a.active .nav-tunein-icon,
.nav-links a.active .nav-rb-icon,
.nav-links a.active .nav-device-icon {
.nav-links a.active .nav-device-icon,
.nav-links a.active .nav-url-icon {
filter: invert(1);
}
}
.nav-tunein-icon, .nav-rb-icon, .nav-device-icon { height: 20px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; }
.nav-tunein-icon, .nav-rb-icon, .nav-device-icon, .nav-url-icon { height: 20px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; }
.nav-discover-icon { height: 24px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; }
.nav-discover-icon.buzzing { animation: buzzing 0.3s linear infinite; opacity: 1; }
@@ -229,6 +231,7 @@ img { display: block; max-width: 100%; }
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon,
.nav-links a:hover .nav-rb-icon, .nav-links a.active .nav-rb-icon,
.nav-links a:hover .nav-device-icon, .nav-links a.active .nav-device-icon,
.nav-links a:hover .nav-url-icon, .nav-links a.active .nav-url-icon,
.nav-links .btn-icon:hover .nav-discover-icon { opacity: 1; }
/* ── Main content ─────────────────────────────────────────────────────────── */
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

@@ -45,4 +45,9 @@ export const api = {
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
playURL: (deviceId, url, name, imageUrl, serviceUrl) => req(`/api/play-url/${deviceId}`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ url, name, imageUrl, serviceUrl }),
}),
};
@@ -10,6 +10,7 @@ import { Zone } from './components/Zone.js';
import { Recents } from './components/Recents.js';
import { TuneInBrowser } from './components/TuneInBrowser.js';
import { RadioBrowser } from './components/RadioBrowser.js';
import { PlayURL } from './components/PlayURL.js';
import { api } from './api.js';
const html = htm.bind(h);
@@ -68,6 +69,7 @@ function App() {
}
if (page === 'tunein') return 'TuneIn';
if (page === 'radiobrowser') return 'RadioBrowser';
if (page === 'playurl') return 'Play URL';
return 'AfterTouch';
};
@@ -169,6 +171,12 @@ function App() {
>
<img src="/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
</a>
<a href="#" class="${page === 'playurl' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('playurl'); }}
title="Play URL"
>
<img src="/static/img/link-mono.svg" alt="Play URL" class="nav-url-icon" />
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
@@ -196,6 +204,8 @@ function App() {
<${TuneInBrowser} key="tunein-browser" devices=${devices} />
` : page === 'radiobrowser' ? html`
<${RadioBrowser} key="radiobrowser-browser" devices=${devices} />
` : page === 'playurl' ? html`
<${PlayURL} key="play-url" devices=${devices} serverServiceUrl=${version?.service_url || ''} />
` : null}
</main>
@@ -0,0 +1,109 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
const LS_KEY = 'aftertouch_service_url';
export function PlayURL({ devices, serverServiceUrl }) {
const [url, setUrl] = useState('');
const [name, setName] = useState('');
const [serviceUrl, setServiceUrl] = useState(() => localStorage.getItem(LS_KEY) || '');
const [pendingPlay, setPendingPlay] = useState(null);
const [status, setStatus] = useState(null);
useEffect(() => {
if (serverServiceUrl && !localStorage.getItem(LS_KEY)) {
setServiceUrl(serverServiceUrl);
}
}, [serverServiceUrl]);
function onServiceUrlChange(val) {
setServiceUrl(val);
if (val) {
localStorage.setItem(LS_KEY, val);
} else {
localStorage.removeItem(LS_KEY);
}
}
function startPlay() {
const trimmedUrl = url.trim();
if (!trimmedUrl) return;
setStatus(null);
setPendingPlay({ url: trimmedUrl, name: name.trim() || trimmedUrl });
}
async function playOn(deviceId) {
const item = pendingPlay;
setPendingPlay(null);
setStatus('Playing…');
try {
const resp = await api.playURL(deviceId, item.url, item.name, '', serviceUrl.trim());
setStatus(resp.success ? 'Playing — use ★ on the device page to save as preset' : 'Error: ' + (resp.error || 'Unknown error'));
} catch (e) {
setStatus('Error: ' + e.message);
}
}
const deviceEntries = Object.entries(devices);
return html`
<div class="tunein-browser">
<div class="tunein-toolbar">
<input
type="url"
class="tunein-search-input"
placeholder="Stream URL (http://…)"
value=${url}
onInput=${(e) => setUrl(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && startPlay()}
/>
<input
type="text"
class="tunein-search-input"
placeholder="Name (optional)"
value=${name}
style="max-width:160px"
onInput=${(e) => setName(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && startPlay()}
/>
<button class="btn-primary" onClick=${startPlay} disabled=${!url.trim()}> Play</button>
</div>
<div class="tunein-toolbar" style="margin-top:.4rem">
<input
type="url"
class="tunein-search-input"
placeholder="AfterTouch URL (https://…)"
value=${serviceUrl}
onInput=${(e) => onServiceUrlChange(e.target.value)}
title="AfterTouch service base URL — required for LOCAL_INTERNET_RADIO playback and preset save"
/>
</div>
${status && html`<div class="track-meta" style="margin-top:.6rem">${status}</div>`}
${pendingPlay ? html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
<h3 class="picker-title">Play on device</h3>
<p class="picker-item-name">${pendingPlay.name}</p>
<div class="picker-devices">
${deviceEntries.length === 0 ? html`<p class="picker-no-devices">No devices found. Try discovering first.</p>` : null}
${deviceEntries.map(([id, d]) => html`
<button class="picker-device-btn" key=${id} onClick=${() => playOn(id)}>
<div class="picker-device-info">
<span class="picker-device-name">${d.info?.name || id}</span>
<span class="picker-device-ip">${d.info?.ip_address || ''}</span>
</div>
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
</div>
</div>
` : null}
</div>
`;
}
+3 -3
View File
@@ -91,14 +91,14 @@ Run the installer again with the version you want to install. The script backs u
```bash
# 1. Environment variable (works when piping into sh)
VERSION=0.92.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
VERSION=0.98.0 rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh
# 2. Command-line flag (pass args after `sh -s --`)
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.92.0
rw && curl -sSL https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh | sh -s -- --version 0.98.0
# 3. Download first, then run with a flag
curl -sSLo install.sh https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/on-device-install/install.sh
sh install.sh --version 0.92.0
sh install.sh --version 0.98.0
```
Running **without** a version override installs the version hard-coded in the script (the latest release at the time the script was published). That default is updated with each release; if you're running from `main`, it reflects the most recent tagged version.
+5 -5
View File
@@ -7,13 +7,13 @@ set -eo pipefail
# picks up the latest binary without extra arguments.
#
# Override via environment variable or the --version/-v flag:
# VERSION=0.92.0 curl -sSL .../install.sh | sh
# curl -sSL .../install.sh | sh -s -- --version 0.92.0
VERSION=${VERSION:-0.93.1}
# VERSION=0.98.0 curl -sSL .../install.sh | sh
# curl -sSL .../install.sh | sh -s -- --version 0.98.0
VERSION=${VERSION:-0.98.0}
# Parse optional command-line arguments so the script can be invoked as:
# install.sh --version 0.92.0
# install.sh -v 0.92.0
# install.sh --version 0.98.0
# install.sh -v 0.98.0
while [ $# -gt 0 ]; do
case "$1" in
--version|-v)
+134 -192
View File
@@ -1,45 +1,39 @@
Here is a `README.md` you can place next to your install script (or in your repo) to document installation, configuration, updates, and debugging.
# Raspberry Pi installers
Two installer scripts are available, one for each binary:
| Script | Binary | Role | Default port |
|------------------|----------------------|-----------------------------------------|--------------|
| `install.sh` | `soundtouch-service` | Cloud-replacement relay — must run 24/7 | 80 / 443 |
| `install-web.sh` | `soundtouch-web` | Browser control panel — run on demand | 8080 |
Both scripts auto-detect CPU architecture (armv7 / arm64 / amd64), create a systemd unit,
and are safe to re-run for updates.
---
# SoundTouch Service (systemd install)
# soundtouch-service
This setup installs `soundtouch-service` from the official GitHub release and runs it as a hardened systemd service.
It supports:
* Automatic start on boot
* Binding to privileged ports (80 / 443) without running as root
* Config via environment file
* Clean updates
* Safe re-runs of the installer
---
# Installation
Run the installer script:
## Installation
```bash
sudo bash install-soundtouch-service.sh
curl -fsSL -o install.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install.sh
sudo bash install.sh
```
You can override defaults:
Override defaults at install time:
```bash
sudo \
VERSION=v0.93.1 \
VERSION=v0.98.0 \
HOSTNAME_FQDN=soundtouch.local \
HTTP_PORT=80 \
HTTPS_PORT=443 \
bash install-soundtouch-service.sh
bash install.sh
```
---
# Configuration
Configuration lives in:
## Configuration
```
/etc/soundtouch-service/soundtouch-service.env
@@ -61,139 +55,41 @@ SERVER_URL=http://soundtouch.local
HTTPS_SERVER_URL=https://soundtouch.local
```
---
# Important: Applying Configuration Changes
If you change the environment file, you must reload and restart the service.
Full roundtrip:
```bash
sudo systemctl daemon-reload
sudo systemctl restart soundtouch-service
```
Usually `daemon-reload` is only needed if the **unit file** changed.
If only the `.env` file changed:
After editing the env file:
```bash
sudo systemctl restart soundtouch-service
```
---
# Service Management
Check status:
## Service management
```bash
systemctl status soundtouch-service
```
Enable at boot:
```bash
sudo systemctl enable soundtouch-service
```
Disable:
```bash
sudo systemctl enable soundtouch-service # start on boot
sudo systemctl disable soundtouch-service
```
Stop / start manually:
```bash
sudo systemctl stop soundtouch-service
sudo systemctl start soundtouch-service
sudo systemctl restart soundtouch-service
```
---
# Logs & Debugging
View recent logs:
## Logs
```bash
journalctl -u soundtouch-service -e --no-pager
journalctl -u soundtouch-service -e --no-pager # recent
journalctl -u soundtouch-service -f # follow
journalctl -u soundtouch-service -b # this boot
```
Follow logs live:
```bash
journalctl -u soundtouch-service -f
```
Show logs from current boot:
```bash
journalctl -u soundtouch-service -b
```
If the service fails to start:
```bash
systemctl status soundtouch-service --no-pager
```
Look for:
* `bind: permission denied` → capability issue
* `address already in use` → port conflict
* permission errors in DATA_DIR → ownership issue
---
# Port Conflicts
Check if 80/443 are in use:
```bash
sudo ss -tulpn | grep -E ':80|:443'
```
If another service is using the port, either:
* stop/disable that service
* or change `PORT` / `HTTPS_PORT` in the env file
Then restart the service.
---
# Updating to a New Version
To upgrade, simply run the installer with the desired version as an argument:
## Updates
```bash
sudo bash install.sh vX.Y.Z
```
The script will:
The script self-updates, downloads the new binary, backs up the old one to `.old`, and
restarts the service. Your env file and data are preserved.
* Automatically fetch the latest version of the installer script for that release
* Download the new service binary
* Backup the old binary to `.old`
* Overwrite the binary and restart the service
No need to reconfigure anything; your existing `.env` file and data will be preserved.
---
# Reinstall / Reset
To fully reset:
```bash
sudo systemctl stop soundtouch-service
sudo rm -rf /var/lib/soundtouch-service/*
sudo systemctl start soundtouch-service
```
To completely remove:
## Removal
```bash
sudo systemctl disable --now soundtouch-service
@@ -206,72 +102,118 @@ sudo systemctl daemon-reload
---
# Architecture Auto-Detection
# soundtouch-web
The installer auto-detects:
* `linux-armv7`
* `linux-arm64`
* `linux-amd64`
Override manually if needed:
## Installation
```bash
sudo ARCH_ASSET=linux-arm64 bash install-soundtouch-service.sh
curl -fsSL -o install-web.sh \
https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/main/scripts/raspberry-pi/install-web.sh
sudo bash install-web.sh
```
Override defaults at install time:
```bash
sudo \
VERSION=v0.98.0 \
HTTP_PORT=8081 \
bash install-web.sh
```
`soundtouch-web` is **stateless** — it holds no persistent data and can be stopped or
restarted at any time without data loss.
## Configuration
```
/etc/soundtouch-web/soundtouch-web.env
```
Example:
```bash
PORT=8080
BIND_ADDR=
DISCOVERY_INTERFACE=
SOUNDTOUCH_DEVICES=
```
`SOUNDTOUCH_DEVICES` accepts a comma-separated list of IP addresses for manual device
registration (useful when mDNS auto-discovery is unreliable on your network).
After editing the env file:
```bash
sudo systemctl restart soundtouch-web
```
## Port conflicts
Port 8080 is a common default for other services. To use a different port, either pass
`HTTP_PORT=<port>` to the installer, or edit the env file after installation:
```bash
sudo ss -tulpn | grep :8080 # check what's using the port
```
## Service management
```bash
systemctl status soundtouch-web
sudo systemctl enable soundtouch-web # start on boot
sudo systemctl disable soundtouch-web
sudo systemctl stop soundtouch-web
sudo systemctl start soundtouch-web
sudo systemctl restart soundtouch-web
```
## Logs
```bash
journalctl -u soundtouch-web -e --no-pager
journalctl -u soundtouch-web -f
```
## Updates
```bash
sudo bash install-web.sh vX.Y.Z
```
## Removal
```bash
sudo systemctl disable --now soundtouch-web
sudo rm /etc/systemd/system/soundtouch-web.service
sudo rm -rf /etc/soundtouch-web
sudo rm /usr/local/bin/soundtouch-web
sudo systemctl daemon-reload
```
---
# Security Notes
# Architecture auto-detection
The service:
Both installers detect the CPU and pick the matching release asset automatically:
* Runs as a dedicated `soundtouch` system user
* Uses `AmbientCapabilities=CAP_NET_BIND_SERVICE`
* Does not require `setcap`
* Does not run as root
* Uses systemd sandboxing (`ProtectSystem`, `PrivateTmp`, etc.)
| `uname -m` | asset suffix |
|---------------------|---------------|
| `aarch64` | `linux-arm64` |
| `armv7l` / `armv6l` | `linux-armv7` |
| `x86_64` | `linux-amd64` |
Override if needed:
```bash
sudo ARCH_ASSET=linux-arm64 bash install.sh
sudo ARCH_ASSET=linux-arm64 bash install-web.sh
```
---
# Quick Troubleshooting Checklist
# Security
If something does not work:
1. Check status:
```
systemctl status soundtouch-service
```
2. Check logs:
```
journalctl -u soundtouch-service -e
```
3. Confirm ports:
```
ss -tulpn | grep -E ':80|:443'
```
4. Confirm env file:
```
cat /etc/soundtouch-service/soundtouch-service.env
```
5. Restart cleanly:
```
sudo systemctl restart soundtouch-service
```
---
If youd like, I can also provide:
* A `make update` style wrapper
* A rollback mechanism
* Or a self-update script with checksum verification
Both services run as the `soundtouch` system user (no login shell, no home directory
ownership required for `soundtouch-web`). `soundtouch-service` additionally uses
`AmbientCapabilities=CAP_NET_BIND_SERVICE` to bind ports 80/443 without root.
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-web installer (systemd, headless)
#
# Usage:
# sudo bash install-web.sh [vX.Y.Z]
#
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.98.0 \
# HTTP_PORT=8081 \
# bash install-web.sh
#
# Or with a version argument to perform an update:
# sudo bash install-web.sh v0.98.0
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
# - soundtouch-web is stateless (no data directory) — it is safe to stop/restart freely.
# - Default port is 8080 (unprivileged — no special capabilities needed).
# - If soundtouch-service is already installed, soundtouch-web reuses the
# existing soundtouch:soundtouch user/group.
# - Safe to re-run; it will update the binary, env file, and unit and restart.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.98.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
SERVICE_NAME="${SERVICE_NAME:-soundtouch-web}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-web}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-web}"
ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-web.env}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
# Port (unprivileged — no CAP_NET_BIND_SERVICE needed)
HTTP_PORT="${HTTP_PORT:-8080}"
# Optional discovery / device config
BIND_ADDR="${BIND_ADDR:-}"
DISCOVERY_INTERFACE="${DISCOVERY_INTERFACE:-}"
SOUNDTOUCH_DEVICES="${SOUNDTOUCH_DEVICES:-}"
# Override if you want to force a specific asset suffix:
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
ARCH_ASSET="${ARCH_ASSET:-}"
# Internal variables
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")"
IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
apt_install_if_missing() {
log "Installing dependencies: $*"
apt-get update -y
apt-get install -y --no-install-recommends "$@"
}
detect_arch_asset() {
local m
m="$(uname -m)"
case "$m" in
armv7l|armv6l)
echo "linux-armv7"
;;
aarch64)
echo "linux-arm64"
;;
x86_64|amd64)
echo "linux-amd64"
;;
*)
die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)"
;;
esac
}
download_url_for() {
local asset="$1"
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-web-${VERSION}-${asset}"
}
ensure_user_group() {
log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}"
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
groupadd --system "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
useradd --system \
--no-create-home \
--shell /usr/sbin/nologin \
--gid "${SERVICE_GROUP}" \
"${SERVICE_USER}"
fi
}
ensure_dirs() {
log "Creating config directory"
mkdir -p "${CONFIG_DIR}"
chmod 0755 "${CONFIG_DIR}"
}
download_binary() {
local asset url tmp=""
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
url="$(download_url_for "$asset")"
log "Downloading binary for ${asset}: ${url}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "${tmp}/soundtouch-web" "${url}"
else
wget -qO "${tmp}/soundtouch-web" "${url}"
fi
chmod +x "${tmp}/soundtouch-web"
if [[ -f "${BIN_PATH}" ]]; then
log "Backing up existing binary to ${BIN_PATH}.old"
cp -p "${BIN_PATH}" "${BIN_PATH}.old"
fi
install -m 0755 "${tmp}/soundtouch-web" "${BIN_PATH}"
log "Installed binary to ${BIN_PATH}"
}
self_update() {
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
return
fi
local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install-web.sh"
local tmp_script="/tmp/soundtouch-web-install-${VERSION}.sh"
log "Checking for installer updates for ${VERSION}..."
log "URL: ${url}"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL -o "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
else
if ! wget -qO "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
fi
if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then
log "Installer is already up to date."
rm -f "${tmp_script}"
return
fi
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
rm -f "${tmp_script}"
export IS_SELF_UPDATE="true"
export VERSION HTTP_PORT BIND_ADDR DISCOVERY_INTERFACE SOUNDTOUCH_DEVICES
export BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
exec "${SCRIPT_PATH}" "$@"
}
write_env_file() {
log "Updating env file: ${ENV_FILE}"
local vars=(
"PORT=${HTTP_PORT}"
"BIND_ADDR=${BIND_ADDR}"
"DISCOVERY_INTERFACE=${DISCOVERY_INTERFACE}"
"SOUNDTOUCH_DEVICES=${SOUNDTOUCH_DEVICES}"
)
if [[ ! -f "${ENV_FILE}" ]]; then
for entry in "${vars[@]}"; do
echo "${entry}" >> "${ENV_FILE}"
done
else
for entry in "${vars[@]}"; do
local key="${entry%%=*}"
local val="${entry#*=}"
if ! grep -q "^${key}=" "${ENV_FILE}"; then
echo "${key}=${val}" >> "${ENV_FILE}"
fi
done
fi
chmod 0640 "${ENV_FILE}"
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
}
write_systemd_unit() {
log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=Bose SoundTouch Web UI
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
EnvironmentFile=${ENV_FILE}
ExecStart=${BIN_PATH}
Restart=on-failure
RestartSec=2
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.target
EOF
}
reload_enable_start() {
log "Reloading systemd, enabling and starting service"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
systemctl restart "${SERVICE_NAME}.service"
log "Verifying service health..."
local health_url="http://localhost:${HTTP_PORT}/health"
local max_retries=5
local count=0
local success=false
while [[ $count -lt $max_retries ]]; do
if curl -fs "$health_url" >/dev/null 2>&1; then
success=true
break
fi
echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)"
sleep 2
count=$((count+1))
done
if [[ "$success" = true ]]; then
log "✅ soundtouch-web is healthy and responding!"
else
log "⚠️ Service started but did not respond at $health_url within timeout."
log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50"
fi
}
show_status() {
log "Service status"
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
log "Listening socket (:${HTTP_PORT})"
ss -tulpn | grep -E ":${HTTP_PORT}\b" || true
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
log "Firewall check (UFW is active)"
if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW"; then
log "⚠️ UFW is active but port ${HTTP_PORT} might be blocked."
log "Run: sudo ufw allow ${HTTP_PORT}/tcp"
else
log "✅ UFW rule for port ${HTTP_PORT} appears to be in place."
fi
fi
cat <<EOF
Open in your browser:
http://<pi-ip>:${HTTP_PORT}/
soundtouch-web is a control panel — you can stop it when not in use:
sudo systemctl stop ${SERVICE_NAME}
sudo systemctl start ${SERVICE_NAME}
Logs:
journalctl -u ${SERVICE_NAME}.service -e --no-pager
EOF
}
main() {
need_root
ensure_cmd systemctl
ensure_cmd ss
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
apt_install_if_missing curl
fi
self_update "$@"
ensure_user_group
ensure_dirs
download_binary
write_env_file
write_systemd_unit
reload_enable_start
show_status
}
main "$@"
+4 -4
View File
@@ -10,7 +10,7 @@ set -euo pipefail
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.93.1 \
# VERSION=v0.98.0 \
# HOSTNAME_FQDN=soundtouch.local \
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
@@ -18,7 +18,7 @@ set -euo pipefail
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.93.1
# sudo bash install.sh v0.98.0
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
@@ -28,7 +28,7 @@ set -euo pipefail
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
VERSION="${1:-${VERSION:-v0.93.1}}"
VERSION="${1:-${VERSION:-v0.98.0}}"
# Normalize version prefix
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
@@ -117,7 +117,7 @@ detect_arch_asset() {
download_url_for() {
local asset="$1"
# Release asset pattern used by you earlier:
# soundtouch-service-v0.93.1-linux-armv7
# soundtouch-service-v0.98.0-linux-armv7
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
}