diff --git a/pkg/service/soundtouchweb/handler_test.go b/pkg/service/soundtouchweb/handler_test.go index b2e9c3c..35e4771 100644 --- a/pkg/service/soundtouchweb/handler_test.go +++ b/pkg/service/soundtouchweb/handler_test.go @@ -498,7 +498,7 @@ func TestHandleWebSocket_InvalidUpgrade(t *testing.T) { app := createTestApp() // Test without proper WebSocket headers (should fail gracefully) - req := httptest.NewRequest("GET", "/ws", nil) + req := httptest.NewRequest("GET", "/api/control/ws", nil) w := httptest.NewRecorder() // This will fail because it's not a real WebSocket upgrade, but should not panic diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index 34984f2..d89ce4e 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -19,20 +19,22 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov subFS, _ := fs.Sub(StaticFS, "static") r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP) - // WebSocket endpoint - r.Get("/ws", app.HandleWebSocket) - // Health / liveness r.Get("/health", app.HandleHealth) // Player / control API. Per #451 this is the post-merge canonical shape: // device-scoped actions nest under devices/{id}/, so every direct child of - // /api/control is a literal namespace (version, discover, devices, + // /api/control is a literal namespace (version, ws, discover, devices, // providers) — no static-vs-param sibling, so routing never depends on // chi's static-over-param precedence. r.Route("/api/control", func(r chi.Router) { r.Get("/version", app.HandleAPIVersion) + // App-wide event stream: device list, discovery status, per-device + // status updates. The read/event half of the control surface (the + // per-device socket lives at devices/{id}/ws). + r.Get("/ws", app.HandleWebSocket) + r.Post("/discover", func(w http.ResponseWriter, r *http.Request) { app.HandleAPIDiscover(w, r) diff --git a/pkg/service/soundtouchweb/mount_test.go b/pkg/service/soundtouchweb/mount_test.go index 8669696..07dc438 100644 --- a/pkg/service/soundtouchweb/mount_test.go +++ b/pkg/service/soundtouchweb/mount_test.go @@ -21,7 +21,11 @@ func TestMountControlAPIShape(t *testing.T) { var apiRoutes []string + registered := map[string]bool{} + walkErr := chi.Walk(r, func(_, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + registered[route] = true + if strings.HasPrefix(route, "/api/") { apiRoutes = append(apiRoutes, route) } @@ -44,15 +48,12 @@ func TestMountControlAPIShape(t *testing.T) { } } - registered := make(map[string]bool, len(apiRoutes)) - for _, route := range apiRoutes { - registered[route] = true - } - // The provider infix (#451): browsable providers expose global browse - // routes; every provider play nests under devices/{id}/providers/. + // routes; every provider play nests under devices/{id}/providers/. The + // app-wide socket moved from top-level /ws to /api/control/ws. mustExist := []string{ "/api/control/version", + "/api/control/ws", "/api/control/providers/tunein/search", "/api/control/providers/radiobrowser/search", "/api/control/devices/{id}/providers/tunein/play", @@ -66,8 +67,10 @@ func TestMountControlAPIShape(t *testing.T) { } } - // The pre-infix flat paths are gone. + // The pre-infix flat paths are gone, and the app-wide socket no longer + // sits at top-level /ws. mustNotExist := []string{ + "/ws", "/api/control/tunein/search", "/api/control/radiobrowser/search", "/api/control/devices/{id}/play-url", diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index 3d83f32..ae78415 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -91,7 +91,7 @@ function App() { .catch(err => console.error('Failed to fetch version:', err)); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const ws = new WebSocket(`${protocol}//${location.host}/ws`); + const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`); let reconnectTimer; ws.onmessage = (event) => {