mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-21 10:06:14 +00:00
Backend:
- GET /api/zone/{id} — zone info enriched with device names and role flags
- POST /api/zone/{id}/add/{slaveId} — add slave (creates zone if standalone)
- POST /api/zone/{id}/remove/{slaveId} — remove slave from zone
- POST /api/zone/{id}/dissolve — dissolve zone to standalone
- POST /api/zone/{id}/leave — slave leaves its zone (backend finds master)
Frontend (Zone.js):
- Standalone: shows "Group with…" button, opens device picker overlay
- Master: member list with per-row Remove, Add speaker, Dissolve buttons
- Slave: shows master name, Leave zone button
- Lazy-loads on device detail open; refreshes after each zone operation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1003 lines
28 KiB
Go
1003 lines
28 KiB
Go
// Package soundtouchweb provides the SoundTouch web UI handler.
|
|
package soundtouchweb
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/client"
|
|
"github.com/gesellix/bose-soundtouch/pkg/config"
|
|
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
|
"github.com/gesellix/bose-soundtouch/pkg/models"
|
|
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
|
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
// WebApp holds the application state and dependencies.
|
|
type WebApp struct {
|
|
Devices map[string]*webtypes.DeviceConnection
|
|
Upgrader websocket.Upgrader
|
|
WSClients map[*websocket.Conn]bool
|
|
WSMutex sync.RWMutex
|
|
}
|
|
|
|
// NewWebApp creates a bare WebApp with no discovery wired up (used by tests).
|
|
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 },
|
|
},
|
|
}
|
|
}
|
|
|
|
// New creates a WebApp and starts background device discovery.
|
|
func New() *WebApp {
|
|
cfg, err := config.LoadFromEnv()
|
|
if err != nil {
|
|
cfg = config.DefaultConfig()
|
|
}
|
|
|
|
cfg.DiscoveryTimeout = 10 * time.Second
|
|
cfg.CacheEnabled = true
|
|
|
|
app := NewWebApp()
|
|
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
|
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
|
app.discoverDevices(ctx, discoveryService)
|
|
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
|
app.BroadcastDeviceList()
|
|
}()
|
|
|
|
return app
|
|
}
|
|
|
|
// Mount registers all routes on r.
|
|
func (app *WebApp) Mount(r chi.Router) {
|
|
subFS, _ := fs.Sub(staticFS, "static")
|
|
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
|
|
|
r.Get("/ws", app.HandleWebSocket)
|
|
|
|
r.Get("/api/devices", app.HandleAPIDevices)
|
|
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
|
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
|
app.HandleAPIDiscover(w, r)
|
|
|
|
go func() {
|
|
cfg, err := config.LoadFromEnv()
|
|
if err != nil {
|
|
cfg = config.DefaultConfig()
|
|
}
|
|
|
|
cfg.DiscoveryTimeout = 10 * time.Second
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
|
app.discoverDevices(ctx, discovery.NewUnifiedDiscoveryService(cfg))
|
|
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
|
app.BroadcastDeviceList()
|
|
}()
|
|
})
|
|
|
|
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
|
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
|
|
|
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
|
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
|
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
|
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
|
|
|
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
|
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
|
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
|
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
|
r.Get("/api/device-recents/{id}", app.HandleDeviceRecents)
|
|
r.Post("/api/device-play/{id}", app.HandleDevicePlay)
|
|
r.Get("/api/zone/{id}", app.HandleGetZone)
|
|
r.Post("/api/zone/{id}/add/{slaveId}", app.HandleZoneAdd)
|
|
r.Post("/api/zone/{id}/remove/{slaveId}", app.HandleZoneRemove)
|
|
r.Post("/api/zone/{id}/dissolve", app.HandleZoneDissolve)
|
|
r.Post("/api/zone/{id}/leave", app.HandleZoneLeave)
|
|
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
|
|
|
r.Get("/", app.serveIndex)
|
|
r.Get("/devices", app.serveIndex)
|
|
r.Get("/device/*", app.serveIndex)
|
|
}
|
|
|
|
func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) {
|
|
data, _ := staticFS.ReadFile("static/index.html")
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
func (app *WebApp) discoverDevices(ctx context.Context, discoveryService *discovery.UnifiedDiscoveryService) {
|
|
log.Println("Starting device discovery...")
|
|
|
|
devices, err := discoveryService.DiscoverDevices(ctx)
|
|
if err != nil {
|
|
log.Printf("Discovery failed: %v", err)
|
|
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
|
|
|
return
|
|
}
|
|
|
|
log.Printf("Found %d devices", len(devices))
|
|
|
|
for _, device := range devices {
|
|
deviceID := device.Host
|
|
|
|
if _, exists := app.Devices[deviceID]; exists {
|
|
app.Devices[deviceID].LastSeen = time.Now()
|
|
continue
|
|
}
|
|
|
|
clientConfig := &client.Config{
|
|
Host: device.Host,
|
|
Port: device.Port,
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
soundTouchClient := client.NewClient(clientConfig)
|
|
|
|
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
|
if err != nil {
|
|
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
|
continue
|
|
}
|
|
|
|
conn := &webtypes.DeviceConnection{
|
|
Client: soundTouchClient,
|
|
DeviceInfo: deviceInfo,
|
|
LastSeen: time.Now(),
|
|
Status: webtypes.DeviceStatus{
|
|
IsConnected: false,
|
|
LastActivity: time.Now(),
|
|
},
|
|
}
|
|
|
|
go app.UpdateDeviceStatus(deviceID, conn)
|
|
|
|
app.Devices[deviceID] = conn
|
|
|
|
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
|
}
|
|
}
|
|
|
|
// HandleAPIDevices returns all devices as JSON.
|
|
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
devices := make(map[string]interface{})
|
|
for id, device := range app.Devices {
|
|
devices[id] = map[string]interface{}{
|
|
"info": device.DeviceInfo,
|
|
"status": device.Status,
|
|
"lastSeen": device.LastSeen,
|
|
}
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: devices}); 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.Devices[deviceID]
|
|
if !exists {
|
|
app.sendError(w, "Device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
app.UpdateDeviceStatus(deviceID, device)
|
|
|
|
if device.WebSocket == nil {
|
|
go app.ConnectDeviceWebSocket(deviceID, device)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
|
Success: true,
|
|
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
|
}); 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.Devices[deviceID]
|
|
if !exists {
|
|
app.sendError(w, "Device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
if device.WebSocket == nil {
|
|
go app.ConnectDeviceWebSocket(deviceID, device)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
app.handleControlAction(w, r, action, device)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.Play(), "Started playback")
|
|
case "pause":
|
|
if device.Client == nil {
|
|
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.Pause(), "Paused playback")
|
|
case "stop":
|
|
if device.Client == nil {
|
|
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.Stop(), "Stopped playback")
|
|
case "next":
|
|
if device.Client == nil {
|
|
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.NextTrack(), "Next track")
|
|
case "previous":
|
|
if device.Client == nil {
|
|
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.PrevTrack(), "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
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.SendKey(models.KeyMute), "Toggled mute")
|
|
case "preset":
|
|
app.handlePresetControl(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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.SetVolume(volumeReq.Level), fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.SelectPreset(presetID), fmt.Sprintf("Selected preset %d", presetID))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
app.sendControlResponse(w, device.Client.SetBass(bassReq.Level), fmt.Sprintf("Bass set to %d", bassReq.Level))
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
account := r.URL.Query().Get("account")
|
|
app.sendControlResponse(w, device.Client.SelectSource(sourceParam, account), fmt.Sprintf("Selected source %s", sourceParam))
|
|
}
|
|
|
|
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
|
if err != nil {
|
|
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
|
Success: true,
|
|
Data: map[string]string{"message": successMessage},
|
|
}); encErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(statusCode)
|
|
|
|
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: false, Error: message}); err != nil {
|
|
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// HandleDeviceKey sends a key command to a device.
|
|
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "id")
|
|
key := chi.URLParam(r, "key")
|
|
|
|
device, exists := app.Devices[deviceID]
|
|
if !exists {
|
|
app.sendError(w, "Device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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")
|
|
app.sendControlResponse(w, device.Client.SendKey(key), fmt.Sprintf("Sent key command: %s", key))
|
|
}
|
|
|
|
// HandleDirectVolumeControl sets volume 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.Devices[deviceID]
|
|
if !exists {
|
|
app.sendError(w, "Device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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")
|
|
app.sendControlResponse(w, device.Client.SetVolume(volumeLevel), fmt.Sprintf("Volume set to %d", volumeLevel))
|
|
}
|
|
|
|
// HandleDevicePower toggles device power.
|
|
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "id")
|
|
|
|
device, exists := app.Devices[deviceID]
|
|
if !exists {
|
|
app.sendError(w, "Device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
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")
|
|
app.sendControlResponse(w, device.Client.SendKey("POWER"), "Power toggle command sent")
|
|
}
|
|
|
|
// HandleDevicePowerStatus returns a lightweight power status check.
|
|
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "id")
|
|
|
|
device, exists := app.Devices[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")
|
|
|
|
nowPlaying, err := device.Client.GetNowPlaying()
|
|
if err != nil {
|
|
app.sendControlResponse(w, err, "Failed to get power status")
|
|
return
|
|
}
|
|
|
|
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
|
|
|
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
|
Success: true,
|
|
Data: map[string]interface{}{
|
|
"deviceId": deviceID,
|
|
"isPoweredOn": isPoweredOn,
|
|
"source": nowPlaying.Source,
|
|
},
|
|
}); encErr != nil {
|
|
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// HandleTuneInSearch proxies a TuneIn search 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 := bmxpkg.TuneInSearch(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)
|
|
}
|
|
}
|
|
|
|
// HandleTuneInNavigate proxies a TuneIn browse/navigate request to the bmx package.
|
|
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
|
wildcard := chi.URLParam(r, "*")
|
|
|
|
var (
|
|
resp interface{}
|
|
err error
|
|
)
|
|
|
|
if wildcard == "" {
|
|
resp, err = bmxpkg.TuneInNavigate("", nil)
|
|
} else {
|
|
firstSlash := strings.Index(wildcard, "/")
|
|
if firstSlash == -1 {
|
|
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
|
} else {
|
|
pfx := wildcard[:firstSlash]
|
|
rest := wildcard[firstSlash+1:]
|
|
|
|
switch pfx {
|
|
case "sub":
|
|
secondSlash := strings.Index(rest, "/")
|
|
if secondSlash == -1 {
|
|
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
|
} else {
|
|
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
|
if parseErr != nil {
|
|
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
|
} else {
|
|
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
|
}
|
|
}
|
|
case "profiles":
|
|
parts := strings.SplitN(rest, "/", 3)
|
|
if len(parts) < 3 {
|
|
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
|
} else {
|
|
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
|
}
|
|
default:
|
|
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 map key (IP) for the device whose hardware ID matches hwID.
|
|
func (app *WebApp) findIPByHwID(hwID string) string {
|
|
for ip, conn := range app.Devices {
|
|
if conn.DeviceInfo != nil && conn.DeviceInfo.DeviceID == hwID {
|
|
return ip
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// HandleGetZone returns zone info for a device, enriched with device names.
|
|
func (app *WebApp) HandleGetZone(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "id")
|
|
|
|
device, exists := app.Devices[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.Devices[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.Devices[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.Devices[masterIP]
|
|
if !ok {
|
|
app.sendError(w, "Master device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
slaveConn, ok := app.Devices[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.Devices[masterIP]
|
|
if !ok {
|
|
app.sendError(w, "Master device not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
slaveConn, ok := app.Devices[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.Devices[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).
|
|
func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) {
|
|
slaveIP := chi.URLParam(r, "id")
|
|
|
|
slaveConn, ok := app.Devices[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.Devices[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.Devices[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.
|
|
func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
|
|
deviceID := chi.URLParam(r, "id")
|
|
|
|
device, exists := app.Devices[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,
|
|
SourceAccount: req.SourceAccount,
|
|
ItemName: req.ItemName,
|
|
ContainerArt: req.ContainerArt,
|
|
IsPresetable: req.IsPresetable,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// HandlePlayTuneIn plays a TuneIn item on a specific device.
|
|
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.Devices[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
|
|
}
|
|
|
|
itemType := req.Type
|
|
if itemType == "" {
|
|
itemType = "stationurl"
|
|
}
|
|
|
|
contentItem := &models.ContentItem{
|
|
Source: "TUNEIN",
|
|
Type: itemType,
|
|
Location: req.Location,
|
|
ItemName: req.Name,
|
|
IsPresetable: true,
|
|
ContainerArt: req.ContainerArt,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|