Embed web resources in soundtouch-web (#182)

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/181
This commit is contained in:
Tobias Gesellchen
2026-04-23 20:51:00 +02:00
committed by GitHub
parent 885967aafc
commit 522492177d
5 changed files with 102 additions and 121 deletions
+12 -71
View File
@@ -13,6 +13,7 @@ import (
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
@@ -61,7 +62,7 @@ func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
// HandleAPIDevice returns a specific device as JSON
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimPrefix(r.URL.Path, "/api/device/")
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
@@ -98,18 +99,9 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
// HandleAPIControl handles device control commands
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/control/")
deviceID := chi.URLParam(r, "id")
action := chi.URLParam(r, "action")
parts := strings.Split(path, "/")
if len(parts) < 2 {
app.sendError(w, "Invalid control path", http.StatusBadRequest)
return
}
deviceID := parts[0]
action := parts[1]
// Check for empty device ID
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
@@ -323,19 +315,8 @@ func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode i
// HandleDeviceKey handles sending key commands to devices
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
return
}
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-key" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
key := pathParts[4]
deviceID := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
device, exists := app.Devices[deviceID]
if !exists {
@@ -361,20 +342,9 @@ func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
// HandleDirectVolumeControl handles direct volume setting via URL parameter
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
return
}
deviceID := chi.URLParam(r, "id")
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 5 || pathParts[1] != "api" || pathParts[2] != "device-volume" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
volumeLevel, err := strconv.Atoi(pathParts[4])
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
@@ -404,18 +374,7 @@ func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Requ
// HandleDevicePower handles power toggle commands for devices
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
app.sendError(w, "POST required", http.StatusMethodNotAllowed)
return
}
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
@@ -442,18 +401,7 @@ func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
// HandleDevicePowerStatus handles lightweight power status check
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
app.sendError(w, "GET required", http.StatusMethodNotAllowed)
return
}
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-power-status" {
app.sendError(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
deviceID := chi.URLParam(r, "id")
device, exists := app.Devices[deviceID]
if !exists {
@@ -587,14 +535,7 @@ func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
// - /sub/{n}/{encodedURI} → single subsection
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
const navPrefix = "/api/tunein/navigate"
path := r.URL.Path
wildcard := ""
if len(path) > len(navPrefix) {
wildcard = strings.TrimPrefix(path[len(navPrefix):], "/")
}
wildcard := chi.URLParam(r, "*")
var (
resp interface{}
@@ -651,7 +592,7 @@ func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request)
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimPrefix(r.URL.Path, "/api/tunein/play/")
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
app.sendError(w, "Device ID required", http.StatusBadRequest)
return
@@ -2,6 +2,7 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
)
func createTestApp() *WebApp {
@@ -42,6 +44,14 @@ func createTestApp() *WebApp {
return app
}
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestNewWebApp(t *testing.T) {
app := NewWebApp()
@@ -102,24 +112,28 @@ func TestHandleAPIDevice(t *testing.T) {
tests := []struct {
name string
path string
chiID string
expectedStatus int
expectSuccess bool
}{
{
name: "valid device",
path: "/api/device/test-device",
chiID: "test-device",
expectedStatus: http.StatusOK,
expectSuccess: true,
},
{
name: "missing device ID",
path: "/api/device/",
chiID: "",
expectedStatus: http.StatusBadRequest,
expectSuccess: false,
},
{
name: "unknown device",
path: "/api/device/unknown",
chiID: "unknown",
expectedStatus: http.StatusNotFound,
expectSuccess: false,
},
@@ -128,6 +142,9 @@ func TestHandleAPIDevice(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
if tt.chiID != "" {
req = withChiParams(req, map[string]string{"id": tt.chiID})
}
w := httptest.NewRecorder()
app.HandleAPIDevice(w, req)
@@ -157,6 +174,7 @@ func TestHandleAPIControl_InvalidDevice(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/unknown-device/play", nil)
req = withChiParams(req, map[string]string{"id": "unknown-device", "action": "play"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -261,6 +279,7 @@ func TestHandleAPIControl_VolumeValidation(t *testing.T) {
} else {
req = httptest.NewRequest(tt.method, "/api/control/test-device/volume", nil)
}
req = withChiParams(req, map[string]string{"id": "test-device", "action": "volume"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -311,6 +330,7 @@ func TestHandleAPIControl_BassValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/control/test-device/bass", strings.NewReader(tt.body))
req = withChiParams(req, map[string]string{"id": "test-device", "action": "bass"})
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
@@ -358,6 +378,7 @@ func TestHandleAPIControl_PresetValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/control/test-device/preset"+tt.query, nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "preset"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -382,6 +403,7 @@ func TestHandleAPIControl_SourceValidation(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/source", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "source"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -497,6 +519,7 @@ func TestHandleAPIControl_UnsupportedAction(t *testing.T) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/control/test-device/unsupported", nil)
req = withChiParams(req, map[string]string{"id": "test-device", "action": "unsupported"})
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -545,6 +568,7 @@ func BenchmarkHandleAPIDevices(b *testing.B) {
func BenchmarkHandleAPIDevice(b *testing.B) {
app := createTestApp()
req := httptest.NewRequest("GET", "/api/device/test-device", nil)
req = withChiParams(req, map[string]string{"id": "test-device"})
b.ResetTimer()
for i := 0; i < b.N; i++ {
+2 -8
View File
@@ -5,11 +5,11 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
)
@@ -224,13 +224,7 @@ func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection)
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
pathParts := strings.Split(r.URL.Path, "/")
if len(pathParts) < 4 || pathParts[1] != "api" || pathParts[2] != "device-ws" {
http.Error(w, "Invalid path format", http.StatusBadRequest)
return
}
deviceID := pathParts[3]
deviceID := chi.URLParam(r, "id")
if deviceID == "" {
http.Error(w, "Device ID required", http.StatusBadRequest)
return
+43 -42
View File
@@ -3,10 +3,11 @@ package main
import (
"context"
"embed"
"flag"
"io/fs"
"log"
"net/http"
"os"
"time"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
@@ -14,8 +15,12 @@ import (
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/config"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/go-chi/chi/v5"
)
//go:embed static
var staticFS embed.FS
var (
port = flag.String("port", "8080", "Web server port")
_ = flag.String("host", "", "Specific SoundTouch device host (optional)")
@@ -56,29 +61,35 @@ func main() {
}()
// Setup HTTP routes
setupRoutes(app, discoveryService)
r := setupRoutes(app, discoveryService)
// Start web server
log.Printf("SoundTouch Web UI starting on http://localhost:%s", *port)
log.Fatal(http.ListenAndServe(":"+*port, nil))
log.Fatal(http.ListenAndServe(":"+*port, r))
}
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
// Static files - try both relative paths
staticDir := "cmd/soundtouch-web/static/"
if _, err := os.Stat(staticDir); os.IsNotExist(err) {
staticDir = "static/"
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
r := chi.NewRouter()
// Static assets (embedded in binary)
subFS, _ := fs.Sub(staticFS, "static")
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
// Serve index.html for SPA routes
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
data, _ := staticFS.ReadFile("static/index.html")
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write(data)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))
// WebSocket endpoint
http.HandleFunc("/ws", app.HandleWebSocket)
r.Get("/ws", app.HandleWebSocket)
// API endpoints
http.HandleFunc("/api/devices", app.HandleAPIDevices)
http.HandleFunc("/api/device/", app.HandleAPIDevice)
http.HandleFunc("/api/discover", func(w http.ResponseWriter, r *http.Request) {
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)
// Trigger discovery
//nolint:contextcheck // Context is created within goroutine
@@ -97,39 +108,29 @@ func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscov
}()
})
// Device control endpoints
http.HandleFunc("/api/control/", app.HandleAPIControl)
// Device control endpoints (GET for most actions, POST for volume/bass)
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
// TuneIn browse, search, and playback
http.HandleFunc("/api/tunein/search", app.HandleTuneInSearch)
http.HandleFunc("/api/tunein/navigate", app.HandleTuneInNavigate)
http.HandleFunc("/api/tunein/navigate/", app.HandleTuneInNavigate)
http.HandleFunc("/api/tunein/play/", app.HandlePlayTuneIn)
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)
// Enhanced device control endpoints with specific patterns
http.HandleFunc("/api/device-key/", app.HandleDeviceKey)
http.HandleFunc("/api/device-volume/", app.HandleDirectVolumeControl)
http.HandleFunc("/api/device-power/", app.HandleDevicePower)
http.HandleFunc("/api/device-power-status/", app.HandleDevicePowerStatus)
http.HandleFunc("/api/device-ws/", app.HandleDeviceWebSocket)
// Enhanced device control endpoints
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-ws/{id}", app.HandleDeviceWebSocket)
// SPA routes - serve index.html for specific routes only
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// Serve the SPA index.html file for root path
spaPath := staticDir + "index.html"
http.ServeFile(w, r, spaPath)
})
// SPA routes - serve index.html for client-side routing
r.Get("/", serveIndex)
r.Get("/devices", serveIndex)
r.Get("/device/*", serveIndex)
// Additional SPA routes for client-side routing
http.HandleFunc("/devices", func(w http.ResponseWriter, r *http.Request) {
spaPath := staticDir + "index.html"
http.ServeFile(w, r, spaPath)
})
http.HandleFunc("/device/", func(w http.ResponseWriter, r *http.Request) {
spaPath := staticDir + "index.html"
http.ServeFile(w, r, spaPath)
})
return r
}
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
+21
View File
@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -11,8 +12,17 @@ import (
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/go-chi/chi/v5"
)
func withChiParams(r *http.Request, params map[string]string) *http.Request {
rctx := chi.NewRouteContext()
for k, v := range params {
rctx.URLParams.Add(k, v)
}
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
func TestSPARouting(t *testing.T) {
tests := []struct {
name string
@@ -134,6 +144,8 @@ func TestAPIEndpoints(t *testing.T) {
app.HandleAPIDiscover(w, req)
default:
if strings.HasPrefix(tt.path, "/api/device/") {
deviceID := strings.TrimPrefix(tt.path, "/api/device/")
req = withChiParams(req, map[string]string{"id": deviceID})
app.HandleAPIDevice(w, req)
}
}
@@ -200,6 +212,7 @@ func TestControlAPIValidation(t *testing.T) {
method string
body string
expectedStatus int
chiParams map[string]string
}{
{
name: "missing device ID",
@@ -218,18 +231,21 @@ func TestControlAPIValidation(t *testing.T) {
path: "/api/control/nonexistent/invalid",
method: "GET",
expectedStatus: http.StatusNotFound,
chiParams: map[string]string{"id": "nonexistent", "action": "invalid"},
},
{
name: "nonexistent device",
path: "/api/control/nonexistent/play",
method: "GET",
expectedStatus: http.StatusNotFound,
chiParams: map[string]string{"id": "nonexistent", "action": "play"},
},
{
name: "unknown action with valid device",
path: "/api/control/testdevice/unknownaction",
method: "GET",
expectedStatus: http.StatusBadRequest,
chiParams: map[string]string{"id": "testdevice", "action": "unknownaction"},
},
}
@@ -251,6 +267,9 @@ func TestControlAPIValidation(t *testing.T) {
} else {
req = httptest.NewRequest(tt.method, tt.path, nil)
}
if tt.chiParams != nil {
req = withChiParams(req, tt.chiParams)
}
w := httptest.NewRecorder()
app.HandleAPIControl(w, req)
@@ -319,6 +338,8 @@ func TestJSONAPIConsistency(t *testing.T) {
app.HandleAPIDevices(w, req)
default:
if strings.HasPrefix(endpoint, "/api/device/") {
deviceID := strings.TrimPrefix(endpoint, "/api/device/")
req = withChiParams(req, map[string]string{"id": deviceID})
app.HandleAPIDevice(w, req)
}
}