diff --git a/cmd/soundtouch-service/coverage_guard_test.go b/cmd/soundtouch-service/coverage_guard_test.go index f900767..8688f9b 100644 --- a/cmd/soundtouch-service/coverage_guard_test.go +++ b/cmd/soundtouch-service/coverage_guard_test.go @@ -129,7 +129,7 @@ func loadHTTPClientRequests(t *testing.T, dir string) [][2]string { // machine-checked companion to tests/integration/http-client/COVERAGE.md. func TestFrozenRouteContractCoverage(t *testing.T) { server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true) - r := setupRouter(server, nil) + r := setupRouter(server, nil, nil, nil) httpRequests := loadHTTPClientRequests(t, filepath.Join("..", "..", "tests", "integration", "http-client")) diff --git a/cmd/soundtouch-service/deprecated_routes_test.go b/cmd/soundtouch-service/deprecated_routes_test.go index 9dbcba4..123dd3b 100644 --- a/cmd/soundtouch-service/deprecated_routes_test.go +++ b/cmd/soundtouch-service/deprecated_routes_test.go @@ -17,7 +17,7 @@ func TestDeprecatedRouteSignal(t *testing.T) { _ = ds.Initialize() server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false) - r := setupRouter(server, nil) + r := setupRouter(server, nil, nil, nil) ts := httptest.NewServer(r) defer ts.Close() diff --git a/cmd/soundtouch-service/dual_route_equivalence_test.go b/cmd/soundtouch-service/dual_route_equivalence_test.go index ccfacb6..ca2cf7f 100644 --- a/cmd/soundtouch-service/dual_route_equivalence_test.go +++ b/cmd/soundtouch-service/dual_route_equivalence_test.go @@ -24,7 +24,7 @@ func TestDualRouteEquivalence(t *testing.T) { _ = ds.Initialize() server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false) - r := setupRouter(server, nil) + r := setupRouter(server, nil, nil, nil) ts := httptest.NewServer(r) defer ts.Close() diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 2c6efcd..46a1c16 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -29,6 +29,7 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/logbuf" "github.com/gesellix/bose-soundtouch/pkg/service/proxy" "github.com/gesellix/bose-soundtouch/pkg/service/setup" + "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb" "github.com/gesellix/bose-soundtouch/pkg/service/spotify" "github.com/gesellix/bose-soundtouch/pkg/service/stockholm" "github.com/go-chi/chi/v5" @@ -587,7 +588,11 @@ func main() { } } - r := setupRouter(server, stockholmHandler) + // Embedded web UI (soundtouch-web): LAN control UI under /app, control + // API under /api/control. Same LAN-trust tier as /setup, no auth. + webApp, webDiscovery := newEmbeddedWebApp(config.serverURL, ds) + + r := setupRouter(server, stockholmHandler, webApp, webDiscovery) // Bind the listener before logging so we print the true // effective port (handles :0 and catches "address already @@ -1083,7 +1088,52 @@ func startDeviceDiscovery(server *handlers.Server) { }() } -func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *chi.Mux { +// newEmbeddedWebApp builds the soundtouch-web application for embedding in the +// service router: release metadata from the build vars, a loopback ServiceURL +// for the TTS / Play URL proxy (plain HTTP, no CA trust needed), and a device +// seed from the service datastore so the UI shows manually-added speakers even +// when network discovery is disabled. It also returns a discovery service and +// kicks off an initial sweep. +func newEmbeddedWebApp(serverURL string, ds *datastore.DataStore) (*soundtouchweb.WebApp, *discovery.UnifiedDiscoveryService) { + webApp := soundtouchweb.NewWebApp() + webApp.Version = version + webApp.Commit = commit + webApp.Date = date + webApp.RepoURL = repoURL + webApp.ServiceURL = strings.TrimRight(serverURL, "/") + webApp.ExtraDeviceHosts = func() []string { + devices, listErr := ds.ListAllDevices() + if listErr != nil { + log.Printf("web UI: failed to list devices from datastore: %v", listErr) + return nil + } + + hosts := make([]string, 0, len(devices)) + for i := range devices { + if devices[i].IPAddress != "" { + hosts = append(hosts, devices[i].IPAddress) + } + } + + return hosts + } + + webDiscovery := soundtouchweb.NewDiscoveryService("") + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount()) + webApp.DiscoverDevices(ctx, webDiscovery) + webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount()) + webApp.BroadcastDeviceList() + }() + + return webApp, webDiscovery +} + +func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, webApp *soundtouchweb.WebApp, webDiscovery *discovery.UnifiedDiscoveryService) *chi.Mux { r := chi.NewRouter() // CleanPath collapses duplicate slashes ("//bmx/..." -> "/bmx/...") and @@ -1478,6 +1528,14 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) * mountSetupAPI(r) }) + // Embedded web UI: control API under /api/control and the SPA under /app + // (LAN-trust, like /setup). Additive — nothing here collides with the + // service's own /, /health, or /static. Skipped when nil, e.g. unit tests + // that only exercise the service surface. + if webApp != nil { + webApp.MountWeb(r, webDiscovery) + } + if stockholmHandler != nil { stockholmHandler.Mount(r) } diff --git a/cmd/soundtouch-service/router_test.go b/cmd/soundtouch-service/router_test.go index 9f5abe4..dd31bd7 100644 --- a/cmd/soundtouch-service/router_test.go +++ b/cmd/soundtouch-service/router_test.go @@ -13,13 +13,16 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/service/datastore" "github.com/gesellix/bose-soundtouch/pkg/service/handlers" + "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb" "github.com/go-chi/chi/v5" ) func TestPrintRoutes(t *testing.T) { - // Initialize a minimal server to get the router + // Initialize a minimal server to get the router. Pass a web app so the + // snapshot also captures the embedded soundtouch-web surface + // (/api/control + /app); discovery is nil since we only register routes. server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true) - r := setupRouter(server, nil) + r := setupRouter(server, nil, soundtouchweb.NewWebApp(), nil) var routes []string walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { @@ -128,7 +131,7 @@ func TestPUTRenameRoutesToLocalHandler(t *testing.T) { _ = ds.Initialize() server := handlers.NewServer(ds, nil, "http://localhost:8000", false, false, false) - r := setupRouter(server, nil) + r := setupRouter(server, nil, nil, nil) ts := httptest.NewServer(r) defer ts.Close() diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 02421d4..b8227cf 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -33,6 +33,20 @@ GET /accounts/{account}/devices/{device}/presets handlers.( GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm +GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm +GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm +GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm +GET /api/control/devices/{id}/power-status soundtouchweb.(*WebApp).HandleDevicePowerStatus-fm +GET /api/control/devices/{id}/recents soundtouchweb.(*WebApp).HandleDeviceRecents-fm +GET /api/control/devices/{id}/ws soundtouchweb.(*WebApp).HandleDeviceWebSocket-fm +GET /api/control/devices/{id}/zone/ soundtouchweb.(*WebApp).HandleGetZone-fm +GET /api/control/providers/radiobrowser/search soundtouchweb.(*WebApp).HandleRadioBrowserSearch-fm +GET /api/control/providers/tunein/navigate soundtouchweb.(*WebApp).HandleTuneInNavigate-fm +GET /api/control/providers/tunein/navigate/* soundtouchweb.(*WebApp).HandleTuneInNavigate-fm +GET /api/control/providers/tunein/search soundtouchweb.(*WebApp).HandleTuneInSearch-fm +GET /api/control/providers/tunein/search/next soundtouchweb.(*WebApp).HandleTuneInSearchNext-fm +GET /api/control/version soundtouchweb.(*WebApp).HandleAPIVersion-fm +GET /api/control/ws soundtouchweb.(*WebApp).HandleWebSocket-fm GET /api/mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm GET /api/mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm GET /api/mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm @@ -62,6 +76,14 @@ GET /api/setup/settings handlers.( GET /api/setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm GET /api/setup/tts/config handlers.(*Server).HandleTTSConfig-fm GET /api/setup/version handlers.(*Server).HandleGetVersionInfo-fm +GET /app soundtouchweb.(*WebApp).serveIndex-fm +GET /app/device/* soundtouchweb.(*WebApp).serveIndex-fm +GET /app/devices soundtouchweb.(*WebApp).serveIndex-fm +GET /app/playurl soundtouchweb.(*WebApp).serveIndex-fm +GET /app/radiobrowser soundtouchweb.(*WebApp).serveIndex-fm +GET /app/static/* http.Handler.ServeHTTP-fm +GET /app/tts soundtouchweb.(*WebApp).serveIndex-fm +GET /app/tunein soundtouchweb.(*WebApp).serveIndex-fm GET /bmx-icons/* handlers.(*Server).HandleBmxIcons GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm @@ -153,6 +175,20 @@ POST /accounts/{account}/group handlers.( POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm +POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm +POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm +POST /api/control/devices/{id}/play soundtouchweb.(*WebApp).HandleDevicePlay-fm +POST /api/control/devices/{id}/power soundtouchweb.(*WebApp).HandleDevicePower-fm +POST /api/control/devices/{id}/providers/radiobrowser/play soundtouchweb.(*WebApp).HandlePlayRadioBrowser-fm +POST /api/control/devices/{id}/providers/tts/play soundtouchweb.(*WebApp).HandleAPISpeakText-fm +POST /api/control/devices/{id}/providers/tunein/play soundtouchweb.(*WebApp).HandlePlayTuneIn-fm +POST /api/control/devices/{id}/providers/url/play soundtouchweb.(*WebApp).HandlePlayURL-fm +POST /api/control/devices/{id}/volume/{volume} soundtouchweb.(*WebApp).HandleDirectVolumeControl-fm +POST /api/control/devices/{id}/zone/add/{slaveId} soundtouchweb.(*WebApp).HandleZoneAdd-fm +POST /api/control/devices/{id}/zone/dissolve soundtouchweb.(*WebApp).HandleZoneDissolve-fm +POST /api/control/devices/{id}/zone/leave soundtouchweb.(*WebApp).HandleZoneLeave-fm +POST /api/control/devices/{id}/zone/remove/{slaveId} soundtouchweb.(*WebApp).HandleZoneRemove-fm +POST /api/control/discover soundtouchweb.(*WebApp).MountWeb POST /api/mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm POST /api/mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm POST /api/mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index 6e72315..d69abf5 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -12,6 +12,15 @@

Bose SoundTouch Toolkit

+

+ 🎵 Open the Player (Web UI) → + + Control playback, volume, presets, zones, and browse TuneIn / RadioBrowser. + +

+

+ This page is the admin / setup console (migration, settings, diagnostics). +

diff --git a/pkg/service/soundtouchweb/discovery.go b/pkg/service/soundtouchweb/discovery.go index 1cc2b24..6a467e3 100644 --- a/pkg/service/soundtouchweb/discovery.go +++ b/pkg/service/soundtouchweb/discovery.go @@ -86,10 +86,31 @@ func (app *WebApp) AddDeviceByHost(host string, port int, source string) { log.Printf("Added %s device %s (%s) at %s:%d", sanitizeLog(source), sanitizeLog(info.Name), sanitizeLog(info.Type), sanitizeLog(host), port) } -// DiscoverDevices runs an mDNS/UPnP sweep and registers any found -// devices via AddDeviceByHost. Used by the startup goroutine in main -// and by the /api/discover route inside Mount. +// SeedExtraDevices registers any devices reported by the ExtraDeviceHosts hook +// (if set) via AddDeviceByHost. Idempotent: already-known hosts are skipped. +// Used by the embedded build to surface the service datastore's devices even +// when network discovery is disabled; a no-op for standalone soundtouch-web. +func (app *WebApp) SeedExtraDevices() { + if app.ExtraDeviceHosts == nil { + return + } + + for _, host := range app.ExtraDeviceHosts() { + if host == "" { + continue + } + + app.AddDeviceByHost(host, 8090, "service-store") + } +} + +// DiscoverDevices seeds any externally-provided hosts (ExtraDeviceHosts), then +// runs an mDNS/UPnP sweep and registers any found devices via AddDeviceByHost. +// Used by the startup goroutine in main and by the /api/control/discover route +// inside MountWeb. func (app *WebApp) DiscoverDevices(ctx context.Context, discoveryService *discovery.UnifiedDiscoveryService) { + app.SeedExtraDevices() + log.Println("Starting device discovery...") devices, err := discoveryService.DiscoverDevices(ctx) diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index abf450f..2d38048 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -48,6 +48,13 @@ type WebApp struct { // service's self-signed CA. ServiceClient *http.Client + // ExtraDeviceHosts, when set, returns additional device host IPs to + // register alongside mDNS/UPnP discovery. The embedded build in + // soundtouch-service points it at the service datastore's known devices so + // the UI shows manually-added speakers even when network discovery is + // disabled. Standalone soundtouch-web leaves it nil. + ExtraDeviceHosts func() []string + discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus }