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