From 7e696ea0005ae4c68ff89c359f62abacfef4b902 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 17 May 2026 23:35:12 +0200 Subject: [PATCH] refactor(soundtouch-web): package owns discovery + route registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replays app's commit-1 architectural restructure onto current main — mechanical move only, behaviour preserved verbatim. main.go shrinks from 333 to ~190 lines; the binary now orchestrates lifecycle and flag handling, the package owns the WebApp's responsibilities. Moves (no logic change vs the previous main.go bodies): main.go addDevice → (*WebApp).AddDeviceByHost in discovery.go main.go discoverDevices → (*WebApp).DiscoverDevices in discovery.go main.go setupRoutes → (*WebApp).Mount(r, ds) in mount.go inline serveIndex closure → (*WebApp).serveIndex in mount.go New helper: soundtouchweb.NewDiscoveryService(interfaceName) wraps config.LoadFromEnv + cfg adjustments + NewUnifiedDiscoveryService. Single source of truth for the web UI's discovery settings; identical to the inline wiring main.go used to do. main.go still owns (kept verbatim, post-base on main): - --port / --bind / --interface / --devices flags - resolveBindAddr (NIC-name → IP resolution for --bind) - defaultDiscoveryInterface (--bind ↔ --interface defaulting) - Startup goroutine sequence: broadcast start → preseed loop (AddDeviceByHost for each --devices entry) → DiscoverDevices → broadcast complete + device list - http.ListenAndServe Behaviour parity checklist: - Routes registered: identical set (see Mount). /api/discover still reuses the startup discoveryService instance, same as before. - Preseeded --devices still added BEFORE the mDNS/UPnP sweep, so the UI doesn't briefly show empty for hosts that come from --devices. - Discovery interface still pinned via --interface (or inherited from --bind), threaded through NewDiscoveryService. - Static FS still served at /static/*, SPA fallback at / /devices /device/* still hits the same index.html. go build ./... clean. go test ./... clean (only pre-existing TestDocsConsistency fails, untracked-file issue, unrelated). golangci-lint run ./... 0 issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/soundtouch-web/main.go | 154 +------------------------ pkg/service/soundtouchweb/discovery.go | 91 +++++++++++++++ pkg/service/soundtouchweb/mount.go | 75 ++++++++++++ 3 files changed, 171 insertions(+), 149 deletions(-) create mode 100644 pkg/service/soundtouchweb/discovery.go create mode 100644 pkg/service/soundtouchweb/mount.go diff --git a/cmd/soundtouch-web/main.go b/cmd/soundtouch-web/main.go index ceee0ee..94b4f34 100644 --- a/cmd/soundtouch-web/main.go +++ b/cmd/soundtouch-web/main.go @@ -4,27 +4,17 @@ package main import ( "context" "fmt" - "io/fs" "log" "net" "net/http" "os" "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/service/soundtouchweb" - "github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes" "github.com/go-chi/chi/v5" "github.com/urfave/cli/v2" ) -// staticFS is sourced from the soundtouchweb package's embedded -// filesystem. The static assets relocated alongside the handlers in -// the relocation commit; the binary just consumes them. -var staticFS = soundtouchweb.StaticFS - func main() { app := &cli.App{ Name: "soundtouch-web", @@ -82,22 +72,7 @@ func main() { // Create web app without templates (SPA mode) webApp := soundtouchweb.NewWebApp() - // Initialize discovery service - cfg, err := config.LoadFromEnv() - if err != nil { - log.Printf("Failed to load config: %v, using defaults", err) - - cfg = config.DefaultConfig() - } - - cfg.DiscoveryTimeout = 10 * time.Second - cfg.CacheEnabled = true - - if ifaceName != "" { - cfg.DiscoveryInterface = ifaceName - } - - discoveryService := discovery.NewUnifiedDiscoveryService(cfg) + discoveryService := soundtouchweb.NewDiscoveryService(ifaceName) // Discover devices on startup go func() { @@ -107,16 +82,17 @@ func main() { webApp.BroadcastDiscoveryStatus("starting", webApp.DeviceCount()) for _, host := range manualHosts { - addDevice(webApp, host, 8090, "manual") + webApp.AddDeviceByHost(host, 8090, "manual") } - discoverDevices(ctx, webApp, discoveryService) + webApp.DiscoverDevices(ctx, discoveryService) webApp.BroadcastDiscoveryStatus("completed", webApp.DeviceCount()) webApp.BroadcastDeviceList() }() - r := setupRoutes(webApp, discoveryService) + r := chi.NewRouter() + webApp.Mount(r, discoveryService) log.Printf("SoundTouch Web UI starting on http://%s", addr) @@ -210,123 +186,3 @@ func resolveBindAddr(bindAddr string) (string, error) { return "", fmt.Errorf("--bind %q: interface has no usable IPv4 or IPv6 address", bindAddr) } } - -// addDevice registers a SoundTouch device with the WebApp by fetching -// its /info and creating a DeviceConnection. The source label -// ("manual" or "discovered") appears in log lines so the operator can -// tell apart entries that came from --devices from those found via -// mDNS/UPnP. If the host is already known, the existing entry's -// LastSeen is bumped and the function returns without re-fetching. -func addDevice(app *soundtouchweb.WebApp, host string, port int, source string) { - // Fast path: skip the network call if we already know this host. - if app.TouchDevice(host) { - return - } - - c := client.NewClient(&client.Config{ - Host: host, - Port: port, - Timeout: 10 * time.Second, - }) - - info, err := c.GetDeviceInfo() - if err != nil { - log.Printf("Failed to fetch device info from %s (%s): %v", host, source, err) - return - } - - conn := webtypes.NewDeviceConnection(c, info) - if !app.AddDevice(host, conn) { - // Lost a race — another goroutine inserted the same host - // between TouchDevice and AddDevice. AddDevice bumped LastSeen - // on the existing entry; discard our conn. - return - } - - go app.UpdateDeviceStatus(host, conn) - - log.Printf("Added %s device %s (%s) at %s:%d", source, info.Name, info.Type, host, port) -} - -func setupRoutes(app *soundtouchweb.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) - } - - // WebSocket endpoint - r.Get("/ws", app.HandleWebSocket) - - // API endpoints - 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 - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Broadcast discovery start - app.BroadcastDiscoveryStatus("starting", app.DeviceCount()) - - discoverDevices(ctx, app, discoveryService) - - // Broadcast discovery completion and updated device list - app.BroadcastDiscoveryStatus("completed", app.DeviceCount()) - app.BroadcastDeviceList() - }() - }) - - // 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 - 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 - 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 client-side routing - r.Get("/", serveIndex) - r.Get("/devices", serveIndex) - r.Get("/device/*", serveIndex) - - return r -} - -func discoverDevices(ctx context.Context, app *soundtouchweb.WebApp, 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", app.DeviceCount()) - - return - } - - log.Printf("Found %d devices", len(devices)) - - for _, device := range devices { - addDevice(app, device.Host, device.Port, "discovered") - } -} diff --git a/pkg/service/soundtouchweb/discovery.go b/pkg/service/soundtouchweb/discovery.go new file mode 100644 index 0000000..2527f56 --- /dev/null +++ b/pkg/service/soundtouchweb/discovery.go @@ -0,0 +1,91 @@ +package soundtouchweb + +import ( + "context" + "log" + "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/service/soundtouchweb/webtypes" +) + +// NewDiscoveryService loads config and returns a unified discovery service +// preconfigured for the web UI's use (10 s discovery timeout, cache on). +// When discoveryInterface is non-empty, mDNS/UPnP are pinned to that NIC. +func NewDiscoveryService(discoveryInterface string) *discovery.UnifiedDiscoveryService { + cfg, err := config.LoadFromEnv() + if err != nil { + log.Printf("Failed to load config: %v, using defaults", err) + + cfg = config.DefaultConfig() + } + + cfg.DiscoveryTimeout = 10 * time.Second + cfg.CacheEnabled = true + + if discoveryInterface != "" { + cfg.DiscoveryInterface = discoveryInterface + } + + return discovery.NewUnifiedDiscoveryService(cfg) +} + +// AddDeviceByHost registers a SoundTouch device with the WebApp by fetching +// its /info and creating a DeviceConnection. The source label +// ("manual" or "discovered") appears in log lines so the operator can +// tell apart entries that came from --devices from those found via +// mDNS/UPnP. If the host is already known, the existing entry's +// LastSeen is bumped and the function returns without re-fetching. +func (app *WebApp) AddDeviceByHost(host string, port int, source string) { + // Fast path: skip the network call if we already know this host. + if app.TouchDevice(host) { + return + } + + c := client.NewClient(&client.Config{ + Host: host, + Port: port, + Timeout: 10 * time.Second, + }) + + info, err := c.GetDeviceInfo() + if err != nil { + log.Printf("Failed to fetch device info from %s (%s): %v", host, source, err) + return + } + + conn := webtypes.NewDeviceConnection(c, info) + if !app.AddDevice(host, conn) { + // Lost a race — another goroutine inserted the same host + // between TouchDevice and AddDevice. AddDevice bumped LastSeen + // on the existing entry; discard our conn. + return + } + + go app.UpdateDeviceStatus(host, conn) + + log.Printf("Added %s device %s (%s) at %s:%d", source, info.Name, info.Type, 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. +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", app.DeviceCount()) + + return + } + + log.Printf("Found %d devices", len(devices)) + + for _, device := range devices { + app.AddDeviceByHost(device.Host, device.Port, "discovered") + } +} diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go new file mode 100644 index 0000000..b650bde --- /dev/null +++ b/pkg/service/soundtouchweb/mount.go @@ -0,0 +1,75 @@ +package soundtouchweb + +import ( + "context" + "io/fs" + "net/http" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/go-chi/chi/v5" +) + +// Mount registers all routes (static, WebSocket, REST) on r. The +// discovery service is reused by the POST /api/discover handler to +// trigger an on-demand sweep — pass the same instance you used for +// startup discovery so settings (interface, timeout) stay consistent. +func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscoveryService) { + // Static assets (embedded in binary) + subFS, _ := fs.Sub(StaticFS, "static") + r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP) + + // WebSocket endpoint + r.Get("/ws", app.HandleWebSocket) + + // API endpoints + 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 + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Broadcast discovery start + app.BroadcastDiscoveryStatus("starting", app.DeviceCount()) + + app.DiscoverDevices(ctx, discoveryService) + + // Broadcast discovery completion and updated device list + app.BroadcastDiscoveryStatus("completed", app.DeviceCount()) + app.BroadcastDeviceList() + }() + }) + + // 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 + 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 + 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 client-side routing + 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) +}