Files
Bose-SoundTouch/pkg/service/soundtouchweb/handler.go
T
Tobias GesellchenandClaude Opus 5 7bd2a3d9f5 perf(player): confirm a source with one light readback, not three heavy ones
Selecting a source cost up to ~21 speaker requests. Two multipliers, both
removed here.

A match on the first readback only marked the command provisional and left
the later deadlines running, so the happy path always spent all three. The
reason to keep watching is real: /select answers 200 even for a source the
speaker rejects seconds later, surfacing as a transition to an error source.
But the event stream already reports that transition as it happens, and the
effect watching nowPlayingUpdated already turns it into a failure. Polling
on top is re-asking a question we are subscribed to the answer of. The
remaining readbacks are now kept only when the readback itself reports no
live event stream, which is the case they are actually needed for.

Each readback also fetched the whole device, and HandleAPIDevice runs a full
UpdateDeviceStatus: six sequential speaker calls plus /getGroup on a
stereo-capable model, to answer one question, against a device the readback
may be checking on precisely because it is slow. GET
/devices/{id}/now-playing refreshes only /now_playing and returns the same
shape, under FieldNowPlaying's generation so it still orders against push
events and concurrent polls, and reporting to the health tracker like any
other HTTP round.

A confirmed selection on a speaker with a live event stream now costs one
speaker request instead of about twenty-one. A speaker whose events are not
arriving keeps the full three-readback window, at one request each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 20:36:58 +02:00

1829 lines
58 KiB
Go

// Package soundtouchweb contains HTTP handlers for the SoundTouch web UI.
package soundtouchweb
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/service/stations"
"github.com/gesellix/bose-soundtouch/pkg/stereopair"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
// WebApp holds the application state and dependencies.
//
// The device registry (devices map + devicesMu) is encapsulated:
// callers go through GetDevice / DeviceSnapshot / AddDevice /
// TouchDevice / DeviceCount instead of touching the map directly.
// This prevents the concurrent-map-read/write panic that would
// otherwise be reachable any time an HTTP handler runs while
// discovery or the /api/discover endpoint is registering devices.
type WebApp struct {
devicesMu sync.RWMutex
devices map[string]*webtypes.DeviceConnection
Upgrader websocket.Upgrader
// WSClients maps each registered browser WebSocket connection to its own
// write-serialization lock (see withConnWrite). Gorilla permits only one
// concurrent writer per connection; keying the lock per-connection means
// a stalled client's write can never block a write to any OTHER
// connection, unlike a single application-wide write lock would.
WSClients map[*websocket.Conn]*sync.Mutex
WSMutex sync.RWMutex
// DeviceWSClients mirrors WSClients but for HandleDeviceWebSocket's
// per-device status connections (see withDeviceConnWrite). Keyed by the
// webSocketWriter interface rather than *websocket.Conn so tests can
// register a mock writer the same way production code registers a real
// connection. awaitPriorGlobalWebSocketWrites barriers against both
// pools uniformly.
DeviceWSClients map[webSocketWriter]*sync.Mutex
DeviceWSMutex sync.RWMutex
// discoveryPublishMu serializes discoveryStatus publications against
// each other only (Store + client snapshot + fan-out stay ordered across
// concurrent BroadcastDiscoveryStatus calls). It is deliberately NOT
// shared with connection registration: a newly-registering connection
// reads whatever discoveryStatus.Load() currently returns and is never
// blocked by an in-flight publication. This is safe because Store()
// always commits before a publication takes its client snapshot, so a
// registration racing a publication sees either the prior or the new
// value -- never something older than what was last actually stored.
discoveryPublishMu sync.Mutex
// webSocketWriteTimeout bounds each browser WebSocket write so one stalled
// client cannot indefinitely block updates for healthy clients.
webSocketWriteTimeout time.Duration
deviceBroadcastMu sync.Mutex
deviceBroadcastPending bool
deviceBroadcastRunning bool
Version string
Commit string
Date string
RepoURL string
ServiceURL string
// InternalServiceURL is the base URL the player uses for its own
// server-side calls back to the AfterTouch service (currently the TTS
// proxy at /api/setup/tts/speak). The embedded build sets it to the
// service's loopback HTTP listener so those self-calls never depend on TLS
// or the service's self-signed CA. Standalone soundtouch-player leaves it
// empty and falls back to ServiceURL.
InternalServiceURL string
// ServiceClient is used for server-side calls to the AfterTouch service
// (currently the TTS proxy). When nil, serviceHTTPClient falls back to
// http.DefaultClient. Set it via NewServiceHTTPClient to trust the
// service's self-signed CA.
ServiceClient *http.Client
// ExtraDeviceHosts, when set, returns additional device host IPs to
// register alongside mDNS/UPnP discovery. The embedded build in
// soundtouch-service points it at the service datastore's known devices so
// the UI shows manually-added speakers even when network discovery is
// disabled. Standalone soundtouch-player leaves it nil.
//
// A non-nil error means the underlying read failed (e.g. a datastore
// glitch), which callers must NOT treat the same as "zero hosts
// persisted" -- doing so would make a transient read failure look like
// every persisted device is already registered.
ExtraDeviceHosts func() ([]string, error)
// TriggerDiscovery, when set, runs an external discovery sweep instead of
// this app's own mDNS/UPnP. The embedded build wires it to the host
// service's discovery (the single source of truth, which updates the shared
// datastore); DiscoverDevices then re-syncs from ExtraDeviceHosts. Standalone
// soundtouch-player leaves it nil and runs its own sweep.
TriggerDiscovery func(ctx context.Context)
// RemoveDeviceHook, when set, removes a device from the backing store by
// its device ID (MAC). The embedded build wires it to the service's
// datastore removal so a removal from the player UI also clears the
// persisted device; standalone soundtouch-player leaves it nil (no store, so
// removal only prunes the in-memory registry).
RemoveDeviceHook func(deviceID string) error
// seedMu serializes seedExtraDevices runs so the bounded startup retry
// loop (SeedExtraDevicesUntilReady) and a devices-changed-hook-triggered
// SeedExtraDevices never probe the same still-offline host concurrently.
seedMu sync.Mutex
// StereoPairs coordinates persistent ST10 stereo-pair mutations across
// both physical speakers. It is shared for the lifetime of WebApp so its
// mutation lock covers concurrent CLI-like requests from every browser.
StereoPairs StereoPairLifecycle
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
// serviceHTTPClient returns the client used for outbound calls to the
// AfterTouch service, falling back to http.DefaultClient when no CA-trusting
// client was configured.
func (app *WebApp) serviceHTTPClient() *http.Client {
if app.ServiceClient != nil {
return app.ServiceClient
}
return http.DefaultClient
}
// proxyServiceURL returns the base URL for the player's own server-side calls
// back to the AfterTouch service (the TTS proxy). It prefers the loopback
// InternalServiceURL (plain HTTP, no CA needed) and falls back to the public
// ServiceURL for the standalone build where no internal URL is set.
func (app *WebApp) proxyServiceURL() string {
if app.InternalServiceURL != "" {
return app.InternalServiceURL
}
return app.ServiceURL
}
// DeviceEntry pairs a device id with its connection and the LastSeen value
// captured by DeviceSnapshot under the registry lock.
type DeviceEntry struct {
ID string
Device *webtypes.DeviceConnection
LastSeen time.Time
}
// NewWebApp creates a new WebApp instance for SPA mode
func NewWebApp() *WebApp {
app := &WebApp{
devices: make(map[string]*webtypes.DeviceConnection),
WSClients: make(map[*websocket.Conn]*sync.Mutex),
DeviceWSClients: make(map[webSocketWriter]*sync.Mutex),
Upgrader: websocket.Upgrader{
CheckOrigin: checkWebSocketOrigin,
},
}
cleanup, preflight, rename := playerStereoPairGenerationPersistence(app.stereoPairPersistenceClient)
app.StereoPairs = stereopair.NewWithGenerationLifecyclePersistence(app.stereoPairClient, cleanup, preflight, rename)
return app
}
// playerStereoPairGenerationPersistence wires generation-lifecycle hooks for
// contexts with no local datastore of their own (the standalone player, and
// the player component embedded in -service before SetStereoPairGenerationPersistence
// overrides it): cleanup and rename are no-ops, and preflight's read-only
// dangling-generation check is advisory.
//
// A speaker self-reports its own group create/rename/teardown to whatever
// Marge backend it's configured with -- that's the entire reason
// HandleMargeAddGroup/HandleMargeModifyGroup/HandleMargeDeleteGroup exist,
// they're only ever called by speakers, never by us. Proactively pushing the
// same update ourselves would duplicate that against a backend we generally
// can't authenticate to anyway (real Bose cloud, another AfterTouch/SoundCork
// instance, ...). The one part with a distinct purpose -- checking for a
// dangling stale generation before a new Create -- is still attempted, but
// its failure must not block Create: it's a best-effort safety net on top of
// the coordinator's own physical preflight, not the primary guard.
func playerStereoPairGenerationPersistence(
persistenceClient func() *http.Client,
) (stereopair.GenerationCleanup, stereopair.GenerationPreflight, stereopair.GenerationRename) {
cleanup := func(stereopair.GenerationRef) error {
return nil
}
preflight := func(refs []stereopair.GenerationRef) error {
if err := stereopair.EnsureMargeNoGroupGenerations(persistenceClient(), refs); err != nil {
log.Printf("Stereo-pair external generation preflight inconclusive, proceeding: %v", err)
}
return nil
}
rename := func(stereopair.GenerationRef, string) error {
return nil
}
return cleanup, preflight, rename
}
func (app *WebApp) stereoPairPersistenceClient() *http.Client {
base := app.serviceHTTPClient()
if base.Timeout >= stereopair.RequestTimeout {
return base
}
configured := *base
configured.Timeout = stereopair.RequestTimeout
return &configured
}
// SetStereoPairGenerationPersistence overrides both exact post-teardown
// retirement and the read-only pre-create generation barrier.
func (app *WebApp) SetStereoPairGenerationPersistence(
cleanup stereopair.GenerationCleanup,
preflight stereopair.GenerationPreflight,
rename stereopair.GenerationRename,
) {
app.StereoPairs = stereopair.NewWithGenerationLifecyclePersistence(app.stereoPairClient, cleanup, preflight, rename)
}
// stereoPairClient uses a dedicated long-timeout client. Pair creation can
// legitimately span multiple 15-second speaker/Marge retry cycles, while the
// ordinary status clients intentionally use a shorter timeout.
func (app *WebApp) stereoPairClient(host string) (stereopair.Client, error) {
if strings.TrimSpace(host) == "" {
return nil, fmt.Errorf("speaker host is empty")
}
return client.NewClient(&client.Config{Host: host, Timeout: stereopair.RequestTimeout}), nil
}
// GetDevice returns the device for id and whether it exists.
func (app *WebApp) GetDevice(id string) (*webtypes.DeviceConnection, bool) {
app.devicesMu.RLock()
defer app.devicesMu.RUnlock()
device, ok := app.devices[id]
return device, ok
}
// DeviceSnapshot returns device entries taken under a single read lock.
// Callers can iterate the result without holding any registry lock.
// Devices added or removed after the call
// are not reflected. A pointer captured here stays valid even if the
// device is later removed (RemoveDevice only detaches it from the map
// and stops its goroutines), so iterating a stale snapshot is safe.
func (app *WebApp) DeviceSnapshot() []DeviceEntry {
app.devicesMu.RLock()
defer app.devicesMu.RUnlock()
out := make([]DeviceEntry, 0, len(app.devices))
for id, device := range app.devices {
out = append(out, DeviceEntry{
ID: id,
Device: device,
LastSeen: device.LastSeen,
})
}
return out
}
// DeviceCount returns the number of registered devices at call time.
func (app *WebApp) DeviceCount() int {
app.devicesMu.RLock()
defer app.devicesMu.RUnlock()
return len(app.devices)
}
// AddDevice atomically registers conn under id when id is not already
// known. If id existed, its LastSeen is bumped and AddDevice returns
// false (the caller should discard conn). Returns true if conn was
// inserted.
func (app *WebApp) AddDevice(id string, conn *webtypes.DeviceConnection) bool {
app.devicesMu.Lock()
defer app.devicesMu.Unlock()
if existing, ok := app.devices[id]; ok {
existing.LastSeen = time.Now()
return false
}
app.devices[id] = conn
return true
}
// TouchDevice bumps LastSeen for id if it exists; returns true if
// found. Use this as a fast-path check before doing the network work
// needed to construct a new DeviceConnection.
func (app *WebApp) TouchDevice(id string) bool {
app.devicesMu.Lock()
defer app.devicesMu.Unlock()
existing, ok := app.devices[id]
if !ok {
return false
}
existing.LastSeen = time.Now()
return true
}
// RemoveDevice removes the device registered under id and stops its
// background goroutines (status poller + WebSocket reconnect loop) via
// conn.Close. Returns true if id was present. Close runs outside the
// registry lock because it performs network I/O (WebSocket disconnect).
func (app *WebApp) RemoveDevice(id string) bool {
app.devicesMu.Lock()
conn, ok := app.devices[id]
if ok {
delete(app.devices, id)
}
app.devicesMu.Unlock()
if ok {
conn.Close()
}
return ok
}
// removeDeviceIfMatch removes id only when it still points at expected. It is
// used when an asynchronous probe must not delete a newer replacement that was
// registered under the same host.
func (app *WebApp) removeDeviceIfMatch(id string, expected *webtypes.DeviceConnection) bool {
app.devicesMu.Lock()
current, ok := app.devices[id]
if ok && current == expected {
delete(app.devices, id)
} else {
ok = false
}
app.devicesMu.Unlock()
if ok {
expected.Close()
}
return ok
}
// removeDeviceIfMatchOrAbsent is the postcondition check for an explicit
// removal request. The embedded datastore hook can synchronously reseed the
// registry and remove expected before it returns; absence is therefore already
// success, while a different current connection remains protected.
func (app *WebApp) removeDeviceIfMatchOrAbsent(
id string,
expected *webtypes.DeviceConnection,
) bool {
app.devicesMu.Lock()
current, ok := app.devices[id]
if !ok {
app.devicesMu.Unlock()
return true
}
if current != expected {
app.devicesMu.Unlock()
return false
}
delete(app.devices, id)
app.devicesMu.Unlock()
expected.Close()
return true
}
// HandleAPIDevices returns all devices as JSON
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Return all devices as JSON
response := webtypes.APIResponse{
Success: true,
Data: app.deviceViewSnapshot(),
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleDeviceNowPlaying refreshes ONLY /now_playing and returns the device in
// the same shape as HandleAPIDevice.
//
// It exists for the player's source-selection readback, which needs one
// question answered ("what is the speaker playing now?") and nothing else.
// Going through HandleAPIDevice for that costs a full UpdateDeviceStatus, six
// sequential speaker calls plus /getGroup on a stereo-capable model, against a
// device the readback may well be checking on precisely because it is slow.
//
// The refresh runs under FieldNowPlaying's generation, so it orders against
// push events and concurrent polls exactly like any other now-playing write,
// and it reports its outcome to the health tracker like any other HTTP round.
func (app *WebApp) HandleDeviceNowPlaying(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client != nil {
generation := device.BeginFieldPoll(webtypes.FieldNowPlaying)
pollGeneration := device.BeginHTTPPoll()
nowPlaying, err := device.Client.GetNowPlaying()
if err == nil {
device.CompleteFieldPoll(webtypes.FieldNowPlaying, generation, func(status *webtypes.DeviceStatus) {
status.NowPlaying = nowPlaying
status.LastActivity = time.Now()
})
}
device.CompleteHTTPPoll(pollGeneration, err == nil, time.Now(), nil)
}
view, visible := app.deviceViewForID(deviceID)
if !visible {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: view}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIDevice returns a specific device as JSON
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Update device status to get fresh power state
app.UpdateDeviceStatus(deviceID, device)
// Connect WebSocket for real-time updates if not already connected
if device.CurrentWebSocket() == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
// Route through the same stereo-pair projection as HandleAPIDevices and
// the WebSocket frames. A hidden pair member is exactly as unaddressable
// here as it is from the list -- otherwise it would be absent from
// "devices" but still fully fetchable, unprojected, by its own id.
view, visible := app.deviceViewForID(deviceID)
if !visible {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: view,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleDeleteDevice removes a device from the registry and, in the
// embedded build, from the service datastore. The registry is keyed by
// host/IP (the {id} URL param); the datastore is keyed by device ID
// (MAC), so we resolve one to the other via the connection's DeviceInfo
// before cascading. A device still live on the network is re-discovered
// on the next sweep — removal is "remove now", not a permanent ban.
func (app *WebApp) HandleDeleteDevice(w http.ResponseWriter, r *http.Request) {
host := chi.URLParam(r, "id")
if host == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
conn, exists := app.GetDevice(host)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Cascade to the backing store (embedded build only). Standalone
// soundtouch-player has no datastore and leaves the hook nil, so removal
// only prunes the in-memory registry below.
if app.RemoveDeviceHook != nil {
deviceID := ""
if info := conn.Info(); info != nil {
deviceID = info.DeviceID
}
if err := app.RemoveDeviceHook(deviceID); err != nil {
log.Printf("Failed to remove device %s from store: %v", sanitizeLog(host), err)
app.sendError(w, "Failed to remove device from store", http.StatusBadGateway)
return
}
}
if !app.removeDeviceIfMatchOrAbsent(host, conn) {
app.sendError(w, "Device changed during removal", http.StatusConflict)
return
}
app.BroadcastDeviceList()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIControl handles device control commands
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
action := chi.URLParam(r, "action")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.CurrentWebSocket() == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
w.Header().Set("Content-Type", "application/json")
app.handleControlAction(w, r, action, device)
}
// handleControlAction processes different control actions
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
switch action {
case "play":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Play()
app.sendControlResponse(w, err, "Started playback")
case "pause":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Pause()
app.sendControlResponse(w, err, "Paused playback")
case "stop":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.Stop()
app.sendControlResponse(w, err, "Stopped playback")
case "next":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.NextTrack()
app.sendControlResponse(w, err, "Next track")
case "previous":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.PrevTrack()
app.sendControlResponse(w, err, "Previous track")
case "volume":
app.handleVolumeControl(w, r, device)
case "mute":
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SendKey(models.KeyMute)
app.sendControlResponse(w, err, "Toggled mute")
case "preset":
app.handlePresetControl(w, r, device)
case "storepreset":
app.handleStorePreset(w, r, device)
case "bass":
app.handleBassControl(w, r, device)
case "source":
app.handleSourceControl(w, r, device)
default:
app.sendError(w, "Unknown action", http.StatusBadRequest)
}
}
// handleVolumeControl processes volume control requests
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
return
}
var volumeReq webtypes.VolumeRequest
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
return
}
if volumeReq.Level < 0 || volumeReq.Level > 100 {
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SetVolume(volumeReq.Level)
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
}
// handlePresetControl processes preset control requests
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
presetParam := r.URL.Query().Get("id")
if presetParam == "" {
app.sendError(w, "Preset ID required", http.StatusBadRequest)
return
}
presetID, err := strconv.Atoi(presetParam)
if err != nil {
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err = device.Client.SelectPreset(presetID)
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
}
// handleStorePreset stores the currently playing content as a numbered preset.
func (app *WebApp) handleStorePreset(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
presetParam := r.URL.Query().Get("id")
if presetParam == "" {
app.sendError(w, "Preset ID required", http.StatusBadRequest)
return
}
presetID, err := strconv.Atoi(presetParam)
if err != nil {
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err = device.Client.StoreCurrentAsPreset(presetID)
app.sendControlResponse(w, err, fmt.Sprintf("Stored current as preset %d", presetID))
}
// handleBassControl processes bass control requests
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
return
}
var bassReq webtypes.BassRequest
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
return
}
if bassReq.Level < -9 || bassReq.Level > 9 {
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SetBass(bassReq.Level)
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
}
// handleSourceControl processes source control requests. POST with an exact
// {source, account} JSON body is canonical. GET query parameters remain as a
// temporary compatibility surface and are explicitly marked deprecated.
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
var sourceParam, accountParam string
switch r.Method {
case http.MethodPost:
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var sourceReq webtypes.SourceRequest
if err := decoder.Decode(&sourceReq); err != nil {
app.sendError(w, "Invalid source data", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
app.sendError(w, "Invalid source data", http.StatusBadRequest)
return
}
sourceParam = sourceReq.Source
accountParam = sourceReq.Account
case http.MethodGet:
w.Header().Set("Deprecation", "true")
w.Header().Set("Warning", `299 - "GET source control is deprecated; use POST with a JSON body"`)
sourceParam = r.URL.Query().Get("name")
accountParam = r.URL.Query().Get("account")
default:
app.sendError(w, "POST required for source control", http.StatusMethodNotAllowed)
return
}
if sourceParam == "" {
app.sendError(w, "Source name required", http.StatusBadRequest)
return
}
// Forward account verbatim as sourceAccount. Devices with multiple jacks
// that share source="AUX" (e.g. ST-5 CD/Aux inputs) disambiguate them via
// distinct sourceAccount values (AUX, AUX1, …).
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
logPlaybackRequest("source-select", chi.URLParam(r, "id"), sourceParam, accountParam, "", "")
err := device.Client.SelectSource(sourceParam, accountParam)
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
}
// sendControlResponse sends a control command response
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
response := webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": successMessage},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// sendError sends an error response
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := webtypes.APIResponse{
Success: false,
Error: message,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
}
}
// HandleDeviceKey handles sending key commands to devices
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.CurrentWebSocket() == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err := device.Client.SendKey(key)
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
}
// HandleDirectVolumeControl handles direct volume setting via URL parameter
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
return
}
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
// Connect WebSocket for real-time updates if not already connected
if device.CurrentWebSocket() == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
err = device.Client.SetVolume(volumeLevel)
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
}
// HandleDevicePower handles power toggle commands for devices
func (app *WebApp) HandleDevicePower(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
}
// Connect WebSocket for real-time updates if not already connected
if device.CurrentWebSocket() == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
// Send POWER key command to toggle device power
err := device.Client.SendKey("POWER")
app.sendControlResponse(w, err, "Power toggle command sent")
}
// HandleDevicePowerStatus handles lightweight power status check
func (app *WebApp) HandleDevicePowerStatus(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
}
w.Header().Set("Content-Type", "application/json")
// Quick power status check by getting now playing
nowPlaying, err := device.Client.GetNowPlaying()
if err != nil {
app.sendControlResponse(w, err, "Failed to get power status")
return
}
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
response := webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"deviceId": deviceID,
"isPoweredOn": isPoweredOn,
"source": nowPlaying.Source,
},
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// BroadcastDeviceList sends updated device list to all connected WebSocket
// clients. Each client is written under only its own connection lock (see
// withConnWrite), so a stalled/backgrounded client can never block this
// call -- including when it runs synchronously from an HTTP handler such as
// HandleDeleteDevice -- from delivering to every other, healthy client.
func (app *WebApp) BroadcastDeviceList() {
message := webtypes.WebSocketMessage{
Type: "devices",
Data: app.deviceViewSnapshot(),
}
for _, client := range app.globalWebSocketClients() {
if err := app.withConnWrite(client, func(batch webSocketWriteBatch) error {
return batch.writeJSON(client, message)
}); err != nil {
if !errors.Is(err, errConnUnregistered) {
log.Printf("Failed to send device update to WebSocket client: %v", err)
}
app.removeGlobalWebSocketClient(client)
}
}
}
// QueueDeviceListBroadcast schedules one device projection without blocking a
// speaker's event read loop on browser I/O. At most one worker runs per app;
// events during a slow write are coalesced into one follow-up snapshot rather
// than expanding into an unbounded queue or set of goroutines.
func (app *WebApp) QueueDeviceListBroadcast() {
app.deviceBroadcastMu.Lock()
app.deviceBroadcastPending = true
if app.deviceBroadcastRunning {
app.deviceBroadcastMu.Unlock()
return
}
app.deviceBroadcastRunning = true
app.deviceBroadcastMu.Unlock()
go app.runDeviceListBroadcasts()
}
func (app *WebApp) runDeviceListBroadcasts() {
for {
app.deviceBroadcastMu.Lock()
if !app.deviceBroadcastPending {
app.deviceBroadcastRunning = false
app.deviceBroadcastMu.Unlock()
return
}
app.deviceBroadcastPending = false
app.deviceBroadcastMu.Unlock()
app.BroadcastDeviceList()
}
}
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
discoveryStatus := &webtypes.DiscoveryStatus{
Status: status,
DeviceCount: deviceCount,
}
switch status {
case "starting":
discoveryStatus.IsDiscovering = true
case "completed", "failed":
discoveryStatus.IsDiscovering = false
}
_ = app.withDiscoveryStatusWrite(discoveryStatus, func(
_ webSocketWriteBatch,
clients []*websocket.Conn,
) error {
message := webtypes.WebSocketMessage{
Type: "discovery_status",
Data: discoveryStatus,
}
for _, client := range clients {
if err := app.withConnWrite(client, func(batch webSocketWriteBatch) error {
return batch.writeJSON(client, message)
}); err != nil {
if !errors.Is(err, errConnUnregistered) {
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
}
app.removeGlobalWebSocketClient(client)
}
}
return nil
})
}
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
resp, err := stations.Search(stations.ProviderTuneIn, query)
if 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: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInSearchNext returns the next page of TuneIn search results using an opaque cursor.
func (app *WebApp) HandleTuneInSearchNext(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")
if cursor == "" {
app.sendError(w, "cursor parameter required", http.StatusBadRequest)
return
}
resp, err := stations.SearchNext(stations.ProviderTuneIn, cursor)
if 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: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
// Supported path suffixes (relative to /api/tunein/navigate):
// - (empty) → top-level browse
// - /{encodedURI} → browse the given TuneIn URI
// - /sub/{n}/{encodedURI} → single subsection
// - /profiles/{encodedURI} → artist/program profile
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
wildcard := chi.URLParam(r, "*")
resp, err := stations.Navigate(stations.ProviderTuneIn, wildcard)
if 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: resp}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// findIPByHwID returns the registry key (IP) for the device whose
// hardware ID matches hwID. Used by zone handlers to bridge between
// the speaker's hwID-keyed zone protocol and our IP-keyed registry.
// Returns "" when no match is found.
func (app *WebApp) findIPByHwID(hwID string) string {
for _, entry := range app.DeviceSnapshot() {
if info := entry.Device.Info(); info != nil && info.DeviceID == hwID {
return entry.ID
}
}
return ""
}
// HandleGetZone returns zone info for a device, enriched with member
// names and role flags (isMaster / isSlave / isStandalone) computed
// from the perspective of the queried device.
func (app *WebApp) HandleGetZone(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
}
zone, err := device.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
currentHwID := ""
if device.DeviceInfo != nil {
currentHwID = device.DeviceInfo.DeviceID
}
masterIP := app.findIPByHwID(zone.Master)
masterName := ""
if conn, ok := app.GetDevice(masterIP); ok {
if info := conn.Info(); info != nil {
masterName = info.Name
}
}
type memberInfo struct {
IP string `json:"ip"`
HwID string `json:"hwId"`
Name string `json:"name"`
}
members := make([]memberInfo, 0, len(zone.Members))
for _, m := range zone.Members {
name := ""
if conn, ok := app.GetDevice(m.IP); ok {
if info := conn.Info(); info != nil {
name = info.Name
}
}
members = append(members, memberInfo{IP: m.IP, HwID: m.DeviceID, Name: name})
}
isMaster := zone.Master == currentHwID && !zone.IsStandalone()
isSlave := false
for _, m := range zone.Members {
if m.DeviceID == currentHwID {
isSlave = true
break
}
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"masterIp": masterIP,
"masterHwId": zone.Master,
"masterName": masterName,
"members": members,
"isMaster": isMaster,
"isSlave": isSlave,
"isStandalone": !isMaster && !isSlave,
},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleZoneAdd adds a slave device to the zone where {id} is or
// becomes the master.
func (app *WebApp) HandleZoneAdd(w http.ResponseWriter, r *http.Request) {
masterIP := chi.URLParam(r, "id")
slaveIP := chi.URLParam(r, "slaveId")
if masterIP == slaveIP {
app.sendError(w, "A device cannot be added to its own zone", http.StatusBadRequest)
return
}
masterConn, ok := app.GetDevice(masterIP)
if !ok {
app.sendError(w, "Master device not found", http.StatusNotFound)
return
}
slaveConn, ok := app.GetDevice(slaveIP)
if !ok {
app.sendError(w, "Slave device not found", http.StatusNotFound)
return
}
if masterConn.Client == nil || masterConn.DeviceInfo == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
if masterConn.DeviceInfo.DeviceID == slaveConn.DeviceInfo.DeviceID {
app.sendError(w, "A device cannot be added to its own zone", http.StatusBadRequest)
return
}
nowPlaying, err := masterConn.Client.GetNowPlaying()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
sources, err := masterConn.Client.GetSources()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
if !currentSourceAllowsMultiroom(nowPlaying, sources) {
app.sendError(w, "Start a multiroom-capable source before grouping speakers", http.StatusConflict)
return
}
masterHwID := masterConn.DeviceInfo.DeviceID
slaveHwID := slaveConn.DeviceInfo.DeviceID
zone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
var zoneReq *models.ZoneRequest
if zone.IsStandalone() {
zoneReq = models.NewZoneRequest(masterHwID)
} else {
zoneReq = zone.ToZoneRequest()
}
zoneReq.AddMember(slaveHwID, slaveIP)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device added to zone")
}
func currentSourceAllowsMultiroom(nowPlaying *models.NowPlaying, sources *models.Sources) bool {
if nowPlaying == nil || sources == nil {
return false
}
source := strings.TrimSpace(nowPlaying.Source)
if source == "" || source == "STANDBY" || source == "INVALID_SOURCE" {
return false
}
for i := range sources.SourceItem {
item := &sources.SourceItem[i]
if item.Source == source && item.MultiroomAllowed &&
(nowPlaying.SourceAccount == "" || item.SourceAccount == nowPlaying.SourceAccount) {
return true
}
}
return false
}
// HandleZoneRemove removes a slave from the zone.
func (app *WebApp) HandleZoneRemove(w http.ResponseWriter, r *http.Request) {
masterIP := chi.URLParam(r, "id")
slaveIP := chi.URLParam(r, "slaveId")
masterConn, ok := app.GetDevice(masterIP)
if !ok {
app.sendError(w, "Master device not found", http.StatusNotFound)
return
}
slaveConn, ok := app.GetDevice(slaveIP)
if !ok {
app.sendError(w, "Slave device not found", http.StatusNotFound)
return
}
if masterConn.Client == nil || masterConn.DeviceInfo == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
masterHwID := masterConn.DeviceInfo.DeviceID
slaveHwID := slaveConn.DeviceInfo.DeviceID
// Remove a single member with the dedicated /removeZoneSlave endpoint.
// Rebuilding the zone via /setZone with the remaining members does not
// reliably drop a member when the zone has more than one: the speaker only
// goes standalone when the resulting member set is empty, so removing one of
// several members appeared to do nothing (#511). /removeZoneSlave targets the
// specific member.
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.RemoveZoneSlave(masterHwID, slaveHwID, slaveIP), "Device removed from zone")
}
// HandleZoneDissolve dissolves the zone, making all devices standalone.
func (app *WebApp) HandleZoneDissolve(w http.ResponseWriter, r *http.Request) {
masterIP := chi.URLParam(r, "id")
masterConn, ok := app.GetDevice(masterIP)
if !ok {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if masterConn.Client == nil || masterConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zoneReq := models.NewZoneRequest(masterConn.DeviceInfo.DeviceID)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Zone dissolved")
}
// HandleZoneLeave removes the calling device from its zone (slave
// perspective). The slave is identified by {id}; the master is
// located by walking the registry for the hwID the slave's zone
// names as Master, then SetZone is issued against that master.
func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
slaveIP := chi.URLParam(r, "id")
slaveConn, ok := app.GetDevice(slaveIP)
if !ok {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if slaveConn.Client == nil || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zone, err := slaveConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
masterIP := app.findIPByHwID(zone.Master)
if masterIP == "" {
app.sendError(w, "Zone master not found in device list", http.StatusNotFound)
return
}
masterConn, ok := app.GetDevice(masterIP)
if !ok || masterConn.Client == nil {
app.sendError(w, "Master device not available", http.StatusInternalServerError)
return
}
// Drop this slave with the dedicated /removeZoneSlave endpoint sent to the
// master. Rebuilding the zone via /setZone with the remaining members does
// not drop a member from a multi-member zone (the master only goes standalone
// when the resulting set is empty), so leaving a 3+ device zone did nothing
// (#511). zone.Master is the master's hwID.
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w,
masterConn.Client.RemoveZoneSlave(zone.Master, slaveConn.DeviceInfo.DeviceID, slaveIP),
"Left zone")
}
// HandleGetZoneCandidates returns every registered physical device, for the
// "add to zone" picker. Unlike HandleAPIDevices, this deliberately bypasses
// the stereo-pair projection (deviceViewSnapshot): Zone and Group are
// separate, unrelated groupings, and a device that's currently hidden as a
// stereo-pair member (see device_projection.go) must still be an
// independently addressable zone target, exactly as the backend
// HandleZoneAdd/HandleZoneRemove already treat it (both look devices up via
// the raw registry, unaffected by projection).
//
// Deliberately does not exclude the {id} device itself: which candidates to
// exclude (the page's own device, current zone members, ...) is a caller
// concern, not an inherent property of "what devices exist" -- excluding it
// here would make this endpoint silently unusable for any future caller
// that isn't Zone.js's current "add to my own zone" flow.
func (app *WebApp) HandleGetZoneCandidates(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if _, exists := app.GetDevice(deviceID); !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
type zoneCandidate struct {
Info *models.DeviceInfo `json:"info,omitempty"`
}
candidates := make(map[string]zoneCandidate)
for _, entry := range app.DeviceSnapshot() {
if entry.Device == nil {
continue
}
info := entry.Device.Info()
if info == nil {
continue
}
candidates[entry.ID] = zoneCandidate{Info: info}
}
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: candidates,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleDeviceRecents returns recently played items for a device.
func (app *WebApp) HandleDeviceRecents(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
}
recents, err := device.Client.GetRecents()
if 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: recents}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// storedMusicTypeForReplay derives a STORED_MUSIC ContentItem type from the
// speaker-native location, which ends with the item kind (e.g. "1$4$2 TRACK"
// or a container's "… DIR"). Recents don't store the type, and the speaker
// rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. Falls back to
// "track" when the location has no kind suffix.
func storedMusicTypeForReplay(location string) string {
if fields := strings.Fields(location); len(fields) >= 2 {
return strings.ToLower(fields[len(fields)-1])
}
return "track"
}
// HandleDevicePlay plays an arbitrary content item on a device. Generic
// counterpart to HandlePlayTuneIn — used by the Recents panel to replay
// items the speaker reports under /recents, regardless of their source.
func (app *WebApp) HandleDevicePlay(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 {
Source string `json:"source"`
Type string `json:"type"`
Location string `json:"location"`
SourceAccount string `json:"sourceAccount"`
ItemName string `json:"itemName"`
ContainerArt string `json:"containerArt"`
IsPresetable bool `json:"isPresetable"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Location == "" {
app.sendError(w, "location is required", http.StatusBadRequest)
return
}
// Recents don't carry a contentItemType for STORED_MUSIC, and the speaker
// rejects an empty-type STORED_MUSIC select with INVALID_SOURCE. The
// speaker-native location ends with the item kind (e.g. "1$4$2 TRACK"), so
// derive the type from it when the caller didn't supply one.
ciType := req.Type
if ciType == "" && req.Source == "STORED_MUSIC" {
ciType = storedMusicTypeForReplay(req.Location)
}
contentItem := &models.ContentItem{
Source: req.Source,
Type: ciType,
Location: req.Location,
ItemName: req.ItemName,
ContainerArt: req.ContainerArt,
IsPresetable: req.IsPresetable,
}
// Only pass SourceAccount when it's a real credential, not the placeholder
// value that speakers echo back (source name == source account, e.g. "TUNEIN").
if req.SourceAccount != "" && req.SourceAccount != req.Source {
contentItem.SourceAccount = req.SourceAccount
}
logPlaybackRequest("device-play", deviceID, contentItem.Source, contentItem.SourceAccount, contentItem.Location, contentItem.ItemName)
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.ItemName},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// 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-player 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,
}
logPlaybackRequest("play-url", deviceID, contentItem.Source, contentItem.SourceAccount, contentItem.Location, contentItem.ItemName)
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")
versionInfo := map[string]string{
"version": app.Version,
"commit": app.Commit,
"date": app.Date,
"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")
if query == "" {
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
resp, err := stations.Search(stations.ProviderRadioBrowser, query)
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: resp}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandlePlayRadioBrowser plays a RadioBrowser station on a specific device.
func (app *WebApp) HandlePlayRadioBrowser(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
var req struct {
Location string `json:"location"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
ci := stations.ResolveContentItem(stations.PlayItem{
Provider: stations.ProviderRadioBrowser,
Location: req.Location,
Name: req.Name,
})
logPlaybackRequest("radiobrowser", deviceID, ci.Source, ci.SourceAccount, ci.Location, ci.ItemName)
if err := device.Client.SelectContentItem(ci); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: map[string]string{"message": "Playing " + req.Name}}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
}
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
return
}
var req struct {
Location string `json:"location"`
Name string `json:"name"`
Type string `json:"type"`
ContainerArt string `json:"containerArt"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Location == "" {
app.sendError(w, "location is required", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
ci := stations.ResolveContentItem(stations.PlayItem{
Provider: stations.ProviderTuneIn,
Location: req.Location,
Name: req.Name,
Type: req.Type,
ContainerArt: req.ContainerArt,
})
logPlaybackRequest("tunein", deviceID, ci.Source, ci.SourceAccount, ci.Location, ci.ItemName)
if err := device.Client.SelectContentItem(ci); 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)
}
}