feat(service): serve the web UI from soundtouch-service (refs #451)

Fold soundtouch-web into soundtouch-service as an additive mount, so a
single process serves both the speaker/cloud-replacement API and the LAN
control UI. No new auth and no opt-in flag: the web surface sits at the
same LAN-trust tier as /setup (which -web already calls without
credentials), and -web is LAN-only by nature.

- newEmbeddedWebApp builds the web app with release metadata, a loopback
  ServiceURL (plain HTTP, no CA needed) for the TTS / Play URL proxy, and
  an initial discovery sweep. setupRouter gains the web app + discovery
  service and mounts the portable surface (MountWeb) additively:
  /api/control/* and /app/* (+ /app/static/*). The service keeps its own
  /, /health and /static; nothing collides. webApp is optional so the
  router unit tests that only exercise the service surface pass nil.
- Manual devices with discovery off: the web app's ExtraDeviceHosts hook
  is pointed at the service datastore (ListAllDevices), and
  SeedExtraDevices (run from DiscoverDevices, i.e. at startup and on each
  /api/control/discover) registers them via the existing AddDeviceByHost.
  So speakers added via /setup show up in the UI even when periodic
  discovery is disabled.
- The admin page at / now links to the player UI at /app; the speaker /
  JSON contract is unchanged.
- Router golden file regenerated: the diff is purely the additive
  /api/control + /app routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-07 15:02:54 +02:00
co-authored by Claude Opus 4.8
parent 3693cfa65b
commit 99b3f5d0aa
9 changed files with 145 additions and 11 deletions
@@ -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"))
@@ -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()
@@ -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()
+60 -2
View File
@@ -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)
}
+6 -3
View File
@@ -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()
+36
View File
@@ -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