Files
Bose-SoundTouch/pkg/service/soundtouchweb/handler.go
T
Tobias GesellchenandClaude Opus 4.8 169c1c5b9f fix(tts): move TTS endpoints from /mgmt to /setup (no Basic Auth)
The TTS speak/config endpoints were under /mgmt (Basic-Auth protected),
but the soundtouch-web proxy and CLI authenticated with their own
mgmt-password default (empty) while the service defaults to "change_me!",
so speaking from -web returned 401.

This was also inconsistent: the Google API key is configured via the
unauthenticated /setup/settings, and Play URL already proxies to /setup,
so gating only TTS playback behind mgmt auth made no sense. Move
/mgmt/tts/{speak,config} to /setup/tts/{speak,config} (LAN-trust, like
the rest of the setup surface), rename the handlers accordingly, and drop
the now-unused mgmt-credential plumbing from soundtouch-web and the CLI
tts command.

Verified: POST /setup/tts/speak now reaches the handler without auth
(502 only because the test speaker IP is unreachable; previously 401).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00

1270 lines
37 KiB
Go

// Package soundtouchweb contains HTTP handlers for the SoundTouch web UI.
package soundtouchweb
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"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/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 map[*websocket.Conn]bool
WSMutex sync.RWMutex
Version string
Commit string
Date string
RepoURL string
ServiceURL string
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
// DeviceEntry pairs a device id with its connection. Used by
// DeviceSnapshot so callers can iterate without holding the lock.
type DeviceEntry struct {
ID string
Device *webtypes.DeviceConnection
}
// NewWebApp creates a new WebApp instance for SPA mode
func NewWebApp() *WebApp {
return &WebApp{
devices: make(map[string]*webtypes.DeviceConnection),
WSClients: make(map[*websocket.Conn]bool),
Upgrader: websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
},
}
}
// 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 a list of (id, *DeviceConnection) pairs 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; the pointers themselves remain valid because
// nothing deletes from the underlying map today.
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})
}
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
}
// 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
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
response := webtypes.APIResponse{
Success: true,
Data: devices,
}
if err := json.NewEncoder(w).Encode(response); 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.WebSocket == nil {
go app.ConnectDeviceWebSocket(deviceID, device)
}
w.Header().Set("Content-Type", "application/json")
response := webtypes.APIResponse{
Success: true,
Data: map[string]interface{}{
"info": device.DeviceInfo,
"status": device.Status(),
},
}
if err := json.NewEncoder(w).Encode(response); 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.WebSocket == 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
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
sourceParam := r.URL.Query().Get("name")
if sourceParam == "" {
app.sendError(w, "Source name required", http.StatusBadRequest)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
err := device.Client.SelectSource(sourceParam, "")
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.WebSocket == 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.WebSocket == 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.WebSocket == 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
func (app *WebApp) BroadcastDeviceList() {
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
snapshot := app.DeviceSnapshot()
devices := make(map[string]interface{}, len(snapshot))
for _, entry := range snapshot {
devices[entry.ID] = map[string]interface{}{
"info": entry.Device.DeviceInfo,
"status": entry.Device.Status(),
"lastSeen": entry.Device.LastSeen,
}
}
message := webtypes.WebSocketMessage{
Type: "devices",
Data: devices,
}
// Send to all connected clients
var failedClients []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(message); err != nil {
log.Printf("Failed to send device update to WebSocket client: %v", err)
// Mark for removal to avoid modifying map during iteration
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(app.WSClients, client)
client.Close()
}
}
// 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.discoveryStatus.Store(discoveryStatus)
app.WSMutex.RLock()
defer app.WSMutex.RUnlock()
message := webtypes.WebSocketMessage{
Type: "discovery_status",
Data: discoveryStatus,
}
// Send to all connected clients
var failedClients []*websocket.Conn
for client := range app.WSClients {
if err := client.WriteJSON(message); err != nil {
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
// Mark for removal to avoid modifying map during iteration
failedClients = append(failedClients, client)
}
}
// Remove failed clients
for _, client := range failedClients {
delete(app.WSClients, client)
client.Close()
}
}
// 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/{type}/{id}/{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 entry.Device.DeviceInfo != nil && entry.Device.DeviceInfo.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 && conn.DeviceInfo != nil {
masterName = conn.DeviceInfo.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 && conn.DeviceInfo != nil {
name = conn.DeviceInfo.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")
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
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")
}
// 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 || slaveConn.DeviceInfo == nil {
app.sendError(w, "Device not ready", http.StatusInternalServerError)
return
}
zone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
zoneReq := zone.ToZoneRequest()
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "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
}
masterZone, err := masterConn.Client.GetZone()
if err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
zoneReq := masterZone.ToZoneRequest()
zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID)
w.Header().Set("Content-Type", "application/json")
app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Left zone")
}
// 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)
}
}
// 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
}
contentItem := &models.ContentItem{
Source: req.Source,
Type: req.Type,
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
}
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-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")
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
}
if err := stations.Play(device.Client, stations.PlayItem{
Provider: stations.ProviderRadioBrowser,
Location: req.Location,
Name: req.Name,
}); 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
}
if err := stations.Play(device.Client, stations.PlayItem{
Provider: stations.ProviderTuneIn,
Location: req.Location,
Name: req.Name,
Type: req.Type,
ContainerArt: req.ContainerArt,
}); 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)
}
}