// 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 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) } }