mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
The /api/control/{host}/source handler hardcoded an empty sourceAccount,
so devices that share source="AUX" across multiple jacks (e.g. the ST-5
CD/Aux inputs, disambiguated by AUX/AUX1/AUX2) always received
sourceAccount="AUX" and rejected the wrong jack with internal error 1005.
Read the account query parameter and forward it to SelectSource, matching
what the frontend already sends and what the CLI already does.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1292 lines
38 KiB
Go
1292 lines
38 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
|
|
|
|
// 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
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Forward the optional account parameter 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, …).
|
|
accountParam := r.URL.Query().Get("account")
|
|
|
|
if device.Client == nil {
|
|
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
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.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)
|
|
}
|
|
}
|