mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-31 14:57:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b4a51cdb | ||
|
|
6eb3829888 | ||
|
|
e54738d367 | ||
|
|
d70e336e52 | ||
|
|
adcdc26d8d | ||
|
|
a5f5bdb916 | ||
|
|
04f7388051 | ||
|
|
db33f7f22e |
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package main demonstrates content navigation and station management with SoundTouch devices.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -41,12 +42,14 @@ func main() {
|
||||
func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
// 1. Browse TuneIn content
|
||||
fmt.Println("📻 Step 1: Browsing TuneIn stations...")
|
||||
|
||||
if err := browseTuneInStations(c); err != nil {
|
||||
return fmt.Errorf("failed to browse TuneIn: %w", err)
|
||||
}
|
||||
|
||||
// 2. Search for specific content
|
||||
fmt.Println("\n🔍 Step 2: Searching for jazz stations...")
|
||||
|
||||
searchResults, err := searchForJazzStations(c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to search stations: %w", err)
|
||||
@@ -54,6 +57,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 3. Add and play a station
|
||||
fmt.Println("\n➕ Step 3: Adding and playing a station...")
|
||||
|
||||
if err := addAndPlayStation(c, searchResults); err != nil {
|
||||
fmt.Printf("⚠️ Could not add station: %v\n", err)
|
||||
// Continue with demo even if this fails
|
||||
@@ -61,6 +65,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 4. Demonstrate Pandora search (if account available)
|
||||
fmt.Println("\n🎵 Step 4: Demonstrating Pandora search...")
|
||||
|
||||
if err := demonstratePandoraSearch(c); err != nil {
|
||||
fmt.Printf("⚠️ Pandora search not available: %v\n", err)
|
||||
// Continue with demo
|
||||
@@ -68,6 +73,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 5. Browse stored music (if available)
|
||||
fmt.Println("\n💿 Step 5: Browsing stored music...")
|
||||
|
||||
if err := browseStoredMusic(c); err != nil {
|
||||
fmt.Printf("⚠️ Stored music not available: %v\n", err)
|
||||
// Continue with demo
|
||||
@@ -75,6 +81,7 @@ func demonstrateNavigationAndStations(c *client.Client) error {
|
||||
|
||||
// 6. Search Spotify content (if account available)
|
||||
fmt.Println("\n🎧 Step 6: Demonstrating Spotify search...")
|
||||
|
||||
if err := demonstrateSpotifySearch(c); err != nil {
|
||||
fmt.Printf("⚠️ Spotify search not available: %v\n", err)
|
||||
// Continue with demo
|
||||
@@ -95,8 +102,10 @@ func browseTuneInStations(c *client.Client) error {
|
||||
|
||||
if len(response.Items) > 0 {
|
||||
fmt.Printf(" 🎵 Sample stations:\n")
|
||||
|
||||
for i, item := range response.Items[:min(5, len(response.Items))] {
|
||||
fmt.Printf(" %d. %s\n", i+1, item.GetDisplayName())
|
||||
|
||||
if item.IsPlayable() {
|
||||
fmt.Printf(" ▶️ Playable\n")
|
||||
} else if item.IsDirectory() {
|
||||
@@ -125,13 +134,16 @@ func searchForJazzStations(c *client.Client) (*models.SearchStationResponse, err
|
||||
if len(songs) > 0 {
|
||||
fmt.Printf(" 🎵 Songs (%d): %s\n", len(songs), songs[0].GetDisplayName())
|
||||
}
|
||||
|
||||
if len(artists) > 0 {
|
||||
fmt.Printf(" 🎤 Artists (%d): %s\n", len(artists), artists[0].GetDisplayName())
|
||||
}
|
||||
|
||||
if len(stations) > 0 {
|
||||
fmt.Printf(" 📻 Stations (%d):\n", len(stations))
|
||||
for i, station := range stations[:min(3, len(stations))] {
|
||||
fmt.Printf(" %d. %s (Token: %s)\n", i+1, station.GetDisplayName(), station.Token)
|
||||
|
||||
for i := range stations[:min(3, len(stations))] {
|
||||
fmt.Printf(" %d. %s (Token: %s)\n", i+1, stations[i].GetDisplayName(), stations[i].Token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +188,7 @@ func addAndPlayStation(c *client.Client, searchResults *models.SearchStationResp
|
||||
return nil
|
||||
}
|
||||
|
||||
func demonstratePandoraSearch(c *client.Client) error {
|
||||
func demonstratePandoraSearch(_ *client.Client) error {
|
||||
// Note: This would require a valid Pandora account
|
||||
// For demo purposes, we'll show how it would work
|
||||
fmt.Printf(" 🎵 Pandora search requires a valid source account\n")
|
||||
@@ -190,7 +202,7 @@ func demonstratePandoraSearch(c *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func browseStoredMusic(c *client.Client) error {
|
||||
func browseStoredMusic(_ *client.Client) error {
|
||||
// Note: This would require a valid device ID for stored music
|
||||
fmt.Printf(" 💿 Stored music browsing requires device ID\n")
|
||||
fmt.Printf(" 💡 Example usage:\n")
|
||||
@@ -204,7 +216,7 @@ func browseStoredMusic(c *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func demonstrateSpotifySearch(c *client.Client) error {
|
||||
func demonstrateSpotifySearch(_ *client.Client) error {
|
||||
// Note: This would require a valid Spotify account
|
||||
fmt.Printf(" 🎧 Spotify search requires a valid source account\n")
|
||||
fmt.Printf(" 💡 Example usage:\n")
|
||||
@@ -218,14 +230,6 @@ func demonstrateSpotifySearch(c *client.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to get minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println("🎵 SoundTouch Navigation & Station Management Demo")
|
||||
fmt.Println()
|
||||
|
||||
@@ -2,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
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, 1–10).
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 you’d 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.
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user