mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 09:06:14 +00:00
The web player is intrinsically LAN-resident: it reaches speakers directly and only delegates cloud-only features (e.g. TTS) to a possibly-remote AfterTouch service via --service-url. That is exactly what a cloud-hosted soundtouch-service cannot do, so the standalone player binary stays useful and is not being deprecated. Rename it to state its purpose, with a transition window so existing downloads keep working. - cmd/soundtouch-web -> cmd/soundtouch-player; CLI name is now soundtouch-player. When the binary is invoked under its old name it prints a one-line rename notice (filepath.Base(os.Args[0])). - Build/release both names from the same source: Makefile (build-player + build-web alias, dev-player* targets), Dockerfile (soundtouch-player image + transitional soundtouch-web image), release.yml and ci.yml (player + web artifacts, checksums, Docker images; release notes announce the rename). The soundtouch-web binary, image, and install script remain a transitional alias to be dropped in a future release (which will break stale fetch scripts and nudge users to the release notes). - scripts/raspberry-pi/install-player.sh is canonical; install-web.sh keeps working but warns. - Sweep docs, code comments, user-facing strings, and assets (soundtouch-web-ui.png, soundtouch-web-tunein.png, soundtouch-web-roadmap.md) to soundtouch-player; README documents the rename and why the player remains separate from the embedded /app. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
153 lines
5.9 KiB
Go
153 lines
5.9 KiB
Go
package soundtouchweb
|
|
|
|
import (
|
|
"context"
|
|
"io/fs"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// MountWeb registers the portable soundtouch-player surface on r: the embedded
|
|
// assets (/app/static/*), the control API (/api/control/*), and the SPA
|
|
// (/app/*). It is self-contained under /api/control and /app, registering
|
|
// nothing outside those subtrees (no /, no /health), so it can be mounted into
|
|
// another router (e.g. soundtouch-service) additively. The discovery service
|
|
// is reused by POST /api/control/discover for on-demand sweeps; pass the same
|
|
// instance used for startup discovery so settings (interface, timeout) stay
|
|
// consistent.
|
|
func (app *WebApp) MountWeb(r chi.Router, discoveryService *discovery.UnifiedDiscoveryService) {
|
|
// Embedded assets, served under the /app subtree so nothing contends with a
|
|
// host router's own /static (e.g. the Stockholm bridge's root catch-all).
|
|
subFS, _ := fs.Sub(StaticFS, "static")
|
|
r.Get("/app/static/*", http.StripPrefix("/app/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
|
|
|
// 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, 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)
|
|
|
|
// Trigger discovery
|
|
//nolint:contextcheck // Context is created within goroutine
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
app.BroadcastDiscoveryStatus("starting", app.DeviceCount())
|
|
|
|
app.DiscoverDevices(ctx, discoveryService)
|
|
|
|
app.BroadcastDiscoveryStatus("completed", app.DeviceCount())
|
|
app.BroadcastDeviceList()
|
|
}()
|
|
})
|
|
|
|
// One /devices subrouter holds both the list and the /{id} subtree (the
|
|
// issue #285 single-subrouter lesson). Under /{id} every child is a
|
|
// literal action.
|
|
r.Route("/devices", func(r chi.Router) {
|
|
r.Get("/", app.HandleAPIDevices)
|
|
|
|
r.Route("/{id}", func(r chi.Router) {
|
|
r.Get("/", app.HandleAPIDevice)
|
|
r.Delete("/", app.HandleDeleteDevice)
|
|
r.Post("/key/{key}", app.HandleDeviceKey)
|
|
r.Post("/volume/{volume}", app.HandleDirectVolumeControl)
|
|
r.Post("/power", app.HandleDevicePower)
|
|
r.Get("/power-status", app.HandleDevicePowerStatus)
|
|
r.Get("/recents", app.HandleDeviceRecents)
|
|
// Low-level "play this ContentItem" primitive (not a provider).
|
|
r.Post("/play", app.HandleDevicePlay)
|
|
// Generic key / preset / source / bass actions.
|
|
r.Get("/action/{action}", app.HandleAPIControl)
|
|
r.Post("/action/{action}", app.HandleAPIControl)
|
|
r.Get("/ws", app.HandleDeviceWebSocket)
|
|
|
|
r.Route("/zone", func(r chi.Router) {
|
|
r.Get("/", app.HandleGetZone)
|
|
r.Post("/add/{slaveId}", app.HandleZoneAdd)
|
|
r.Post("/remove/{slaveId}", app.HandleZoneRemove)
|
|
r.Post("/dissolve", app.HandleZoneDissolve)
|
|
r.Post("/leave", app.HandleZoneLeave)
|
|
})
|
|
|
|
// Play a result from a content provider on this device.
|
|
// Browsable providers (tunein, radiobrowser) take a catalog item;
|
|
// input providers (url, tts) take the raw input.
|
|
r.Route("/providers", func(r chi.Router) {
|
|
r.Post("/tunein/play", app.HandlePlayTuneIn)
|
|
r.Post("/radiobrowser/play", app.HandlePlayRadioBrowser)
|
|
r.Post("/url/play", app.HandlePlayURL)
|
|
// Proxied to the AfterTouch service's /api/setup/tts/speak.
|
|
r.Post("/tts/play", app.HandleAPISpeakText)
|
|
})
|
|
})
|
|
})
|
|
|
|
// Provider browse / search (global, not device-scoped). Only browsable
|
|
// providers (a catalog you search/navigate) appear here; input
|
|
// providers (url, tts) exist solely as a device play above.
|
|
r.Route("/providers", func(r chi.Router) {
|
|
r.Route("/tunein", func(r chi.Router) {
|
|
r.Get("/search", app.HandleTuneInSearch)
|
|
r.Get("/search/next", app.HandleTuneInSearchNext)
|
|
r.Get("/navigate", app.HandleTuneInNavigate)
|
|
r.Get("/navigate/*", app.HandleTuneInNavigate)
|
|
})
|
|
|
|
r.Route("/radiobrowser", func(r chi.Router) {
|
|
r.Get("/search", app.HandleRadioBrowserSearch)
|
|
})
|
|
})
|
|
})
|
|
|
|
// SPA — served under /app/*. The client navigates via component state
|
|
// rather than the URL, so these entries only ensure deep links and
|
|
// refreshes return index.html instead of 404. Per #451 this keeps the
|
|
// whole web UI under one /app subtree, so folding -web into -service is an
|
|
// additive mount.
|
|
r.Get("/app", app.serveIndex)
|
|
r.Get("/app/devices", app.serveIndex)
|
|
r.Get("/app/device/*", app.serveIndex)
|
|
r.Get("/app/tunein", app.serveIndex)
|
|
r.Get("/app/radiobrowser", app.serveIndex)
|
|
r.Get("/app/playurl", app.serveIndex)
|
|
r.Get("/app/tts", app.serveIndex)
|
|
}
|
|
|
|
// Mount is the standalone soundtouch-player entry point: the portable web surface
|
|
// (see MountWeb) plus the standalone-only liveness endpoint and a / redirect
|
|
// into the app. soundtouch-service does not call this — it mounts MountWeb and
|
|
// keeps its own / (landing page) and /health.
|
|
func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscoveryService) {
|
|
app.MountWeb(r, discoveryService)
|
|
|
|
// Health / liveness (standalone only).
|
|
r.Get("/health", app.HandleHealth)
|
|
|
|
// Standalone convenience: the bare root jumps into the app.
|
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/app", http.StatusFound)
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|