Files
Tobias GesellchenandClaude Opus 4.8 bd62fd6658 refactor: rename soundtouch-web to soundtouch-player (transitional alias) (refs #451)
The web player is intrinsically LAN-resident: it reaches speakers directly
and only delegates cloud-only features (e.g. TTS) to a possibly-remote
AfterTouch service via --service-url. That is exactly what a cloud-hosted
soundtouch-service cannot do, so the standalone player binary stays useful
and is not being deprecated. Rename it to state its purpose, with a
transition window so existing downloads keep working.

- cmd/soundtouch-web -> cmd/soundtouch-player; CLI name is now
  soundtouch-player. When the binary is invoked under its old name it prints
  a one-line rename notice (filepath.Base(os.Args[0])).
- Build/release both names from the same source: Makefile (build-player +
  build-web alias, dev-player* targets), Dockerfile (soundtouch-player image
  + transitional soundtouch-web image), release.yml and ci.yml (player +
  web artifacts, checksums, Docker images; release notes announce the
  rename). The soundtouch-web binary, image, and install script remain a
  transitional alias to be dropped in a future release (which will break
  stale fetch scripts and nudge users to the release notes).
- scripts/raspberry-pi/install-player.sh is canonical; install-web.sh keeps
  working but warns.
- Sweep docs, code comments, user-facing strings, and assets
  (soundtouch-web-ui.png, soundtouch-web-tunein.png, soundtouch-web-roadmap.md)
  to soundtouch-player; README documents the rename and why the player
  remains separate from the embedded /app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:33:39 +02:00

170 lines
5.3 KiB
Go

package soundtouchweb
import (
"context"
"log"
"sync"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
)
// NewDiscoveryService loads config and returns a unified discovery service
// preconfigured for the web UI's use (10 s discovery timeout, cache on).
// When discoveryInterface is non-empty, mDNS/UPnP are pinned to that NIC.
func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryService {
cfg, err := config.LoadFromEnv()
if err != nil {
log.Printf("Failed to load config: %v, using defaults", err)
cfg = config.DefaultConfig()
}
cfg.DiscoveryTimeout = 10 * time.Second
cfg.CacheEnabled = true
if discoveryInterface != "" {
cfg.DiscoveryInterface = discoveryInterface
}
return discovery.NewUnifiedDiscoveryService(cfg)
}
// AddDeviceByHost registers a SoundTouch device with the WebApp by fetching
// its /info and creating a DeviceConnection. The source label
// ("manual" or "discovered") appears in log lines so the operator can
// tell apart entries that came from --devices from those found via
// mDNS/UPnP. If the host is already known, the existing entry's
// LastSeen is bumped and the function returns without re-fetching.
func (app *WebApp) AddDeviceByHost(host string, port int, source string) {
// Fast path: skip the network call if we already know this host.
if app.TouchDevice(host) {
return
}
c := client.NewClient(&client.Config{
Host: host,
Port: port,
Timeout: 10 * time.Second,
})
info, err := c.GetDeviceInfo()
if err != nil {
log.Printf("Failed to fetch device info from %s (%s): %v", sanitizeLog(host), sanitizeLog(source), err)
return
}
// Ensure IPAddress is set for the web UI
if info.IPAddress == "" {
info.IPAddress = host
}
conn := webtypes.NewDeviceConnection(c, info)
if !app.AddDevice(host, conn) {
// Lost a race — another goroutine inserted the same host
// between TouchDevice and AddDevice. AddDevice bumped LastSeen
// on the existing entry; discard our conn.
return
}
go app.UpdateDeviceStatus(host, conn)
// Poll via HTTP every 30 s as a fallback for WebSocket events that the
// speaker does not emit (e.g. Spotify Connect track changes) and for the
// window between a WS disconnect and its reconnect.
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
app.UpdateDeviceStatus(host, conn)
case <-conn.Done():
return
}
}
}()
log.Printf("Added %s device %s (%s) at %s:%d", sanitizeLog(source), sanitizeLog(info.Name), sanitizeLog(info.Type), sanitizeLog(host), port)
}
// SeedExtraDevices registers any devices reported by the ExtraDeviceHosts hook
// (if set) via AddDeviceByHost. Idempotent: already-known hosts are skipped.
// Used by the embedded build to surface the service datastore's devices even
// when network discovery is disabled; a no-op for standalone soundtouch-player.
//
// Hosts are probed concurrently: AddDeviceByHost makes a blocking /info call
// (up to its 10 s timeout) for each unknown host, so an offline speaker in the
// datastore would otherwise stall the whole seed for 10 s, serially. Fanning
// out bounds the cost to roughly a single timeout regardless of how many
// devices are offline. AddDeviceByHost is registry-safe under concurrency.
func (app *WebApp) SeedExtraDevices() {
if app.ExtraDeviceHosts == nil {
return
}
var wg sync.WaitGroup
for _, host := range app.ExtraDeviceHosts() {
if host == "" {
continue
}
wg.Add(1)
go func(h string) {
defer wg.Done()
app.AddDeviceByHost(h, 8090, "service-store")
}(host)
}
wg.Wait()
}
// DiscoverDevices refreshes the device registry. When TriggerDiscovery is set
// (embedded build), it runs the host service's discovery so the shared store is
// refreshed, then re-syncs from ExtraDeviceHosts — it does NOT run its own
// mDNS/UPnP, so the embedded build never duplicates the service's discovery.
// When discoveryService is non-nil (standalone soundtouch-player), it runs an
// mDNS/UPnP sweep and registers any found devices via AddDeviceByHost.
// Used by the startup goroutine in main and by the /api/control/discover route
// inside MountWeb.
func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discovery.UnifiedDiscoveryService) {
// External discovery (embedded build): run the host service's sweep so the
// shared datastore is refreshed.
if app.TriggerDiscovery != nil {
app.TriggerDiscovery(ctx)
}
// Re-sync from the external device source (embedded: the service datastore).
// No-op when ExtraDeviceHosts is unset.
app.SeedExtraDevices()
// Own mDNS/UPnP sweep — standalone only. The embedded build passes a nil
// discovery service and relies entirely on the host service's discovery.
if discoveryService == nil {
return
}
log.Println("Starting device discovery...")
devices, err := discoveryService.DiscoverDevices(ctx)
if err != nil {
log.Printf("Discovery failed: %v", err)
app.BroadcastDiscoveryStatus("failed", app.DeviceCount())
return
}
log.Printf("Found %d devices", len(devices))
for _, device := range devices {
app.AddDeviceByHost(device.Host, device.Port, "discovered")
}
}