mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
Wires the soundtouch-player control API for browsing and playing DLNA
media-server content on a speaker, using the validated native STORED_MUSIC
path (the speaker is the DLNA control point; no AfterTouch proxy).
Routes (under /api/control):
- GET /providers/library/servers LAN-wide SSDP sweep (discovery.DiscoverMediaServers)
- GET /devices/{id}/library/servers STORED_MUSIC sources registered on this speaker (+ ready)
- POST /devices/{id}/library/servers register a server (setMusicServiceAccount; 1024 = already present)
- DELETE /devices/{id}/library/servers/{account} unregister
- GET /devices/{id}/library/browse speaker /navigate (root or container) -> location tokens
- POST /devices/{id}/library/play select a STORED_MUSIC ContentItem (type=track)
- GET /app/library SPA deep link
Browsing goes through the speaker's own /navigate so the returned location
tokens are the ones /select accepts; the raw DLNA ContentDirectory IDs are
not playable, so pkg/dlna is intentionally not on this path. All handlers
reuse existing client methods (Navigate, NavigateContainer, SelectContentItem,
GetSources, AddStoredMusicAccount, RemoveStoredMusicAccount) and the existing
APIResponse envelope. Unit tests cover play XML shape, browse mapping,
source filtering, idempotent register, and validation/404s.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
6.4 KiB
Go
166 lines
6.4 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("/library", func(r chi.Router) {
|
|
r.Get("/servers", app.HandleDeviceLibraryServers)
|
|
r.Post("/servers", app.HandleAddLibraryServer)
|
|
r.Delete("/servers/{account}", app.HandleRemoveLibraryServer)
|
|
r.Get("/browse", app.HandleLibraryBrowse)
|
|
r.Post("/play", app.HandlePlayLibrary)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
r.Route("/library", func(r chi.Router) {
|
|
r.Get("/servers", app.HandleDiscoverLibraryServers)
|
|
})
|
|
})
|
|
})
|
|
|
|
// 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)
|
|
r.Get("/app/library", 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)
|
|
}
|