🎉 live server seems to be working now
This commit is contained in:
293
internal/live/http.go
Normal file
293
internal/live/http.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Sendspin/sendspin-go/pkg/sendspin"
|
||||
|
||||
"rpi-sendspin/internal/pipewire"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
mgr *Manager
|
||||
store *Store
|
||||
disc *Discovery
|
||||
roster *Roster
|
||||
srv *sendspin.Server
|
||||
}
|
||||
|
||||
func NewHTTPServer(mgr *Manager, store *Store, disc *Discovery, roster *Roster, srv *sendspin.Server) *HTTPServer {
|
||||
return &HTTPServer{mgr: mgr, store: store, disc: disc, roster: roster, srv: srv}
|
||||
}
|
||||
|
||||
func (h *HTTPServer) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/presets", h.listPresets)
|
||||
mux.HandleFunc("POST /api/presets", h.createPreset)
|
||||
mux.HandleFunc("GET /api/presets/{id}", h.getPreset)
|
||||
mux.HandleFunc("PUT /api/presets/{id}", h.putPreset)
|
||||
mux.HandleFunc("DELETE /api/presets/{id}", h.deletePreset)
|
||||
|
||||
mux.HandleFunc("GET /api/clients", h.listClients)
|
||||
mux.HandleFunc("GET /api/sources", h.listSources)
|
||||
mux.HandleFunc("GET /api/state", h.getState)
|
||||
|
||||
mux.HandleFunc("POST /api/active_preset", h.setActivePreset)
|
||||
mux.HandleFunc("POST /api/playback", h.setPlayback)
|
||||
mux.HandleFunc("POST /api/volume", h.setVolume)
|
||||
|
||||
mux.Handle("/", staticHandler())
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
type setActivePresetReq struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) setActivePreset(w http.ResponseWriter, r *http.Request) {
|
||||
var req setActivePresetReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.mgr.SetActivePreset(req.ID); err != nil {
|
||||
httpError(w, MapManagerError(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type setPlaybackReq struct {
|
||||
Command string `json:"command"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) setPlayback(w http.ResponseWriter, r *http.Request) {
|
||||
var req setPlaybackReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.mgr.SetPlayback(req.Command); err != nil {
|
||||
httpError(w, MapManagerError(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type setVolumeReq struct {
|
||||
Volume int `json:"volume"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) setVolume(w http.ResponseWriter, r *http.Request) {
|
||||
var req setVolumeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.mgr.SetVolume(req.Volume); err != nil {
|
||||
httpError(w, MapManagerError(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) listPresets(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.store.List())
|
||||
}
|
||||
|
||||
func (h *HTTPServer) createPreset(w http.ResponseWriter, r *http.Request) {
|
||||
var p Preset
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if err := h.store.Create(p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) getPreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, ok := h.store.Get(id)
|
||||
if !ok {
|
||||
httpError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) putPreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var p Preset
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
if p.ID == "" {
|
||||
p.ID = id
|
||||
}
|
||||
if p.ID != id {
|
||||
httpError(w, http.StatusBadRequest, "id mismatch")
|
||||
return
|
||||
}
|
||||
if err := h.store.Put(p); err != nil {
|
||||
httpError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *HTTPServer) deletePreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
// If the deleted preset is currently active, transition to off first.
|
||||
active, _, _, _ := h.mgr.State()
|
||||
if active == id {
|
||||
_ = h.mgr.SetActivePreset("")
|
||||
}
|
||||
ok, err := h.store.Delete(id)
|
||||
if err != nil {
|
||||
httpError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
httpError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type clientView struct {
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
LastSeen time.Time `json:"last_seen,omitempty"`
|
||||
// Source indicates how this entry was discovered:
|
||||
// "connected" → currently in the active group (after ClientFilter accepted hello)
|
||||
// "roster" → handshaked at least once (we know its client_id; not currently in group)
|
||||
// "mdns" → only seen via mDNS; never handshaked, no client_id known
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) listClients(w http.ResponseWriter, _ *http.Request) {
|
||||
// Build a unified view of every speaker we know about, keyed by
|
||||
// client_id wherever possible so the UI can present a real, stable
|
||||
// identifier rather than the mDNS instance name (which differs from
|
||||
// client_id on most ESPHome / firmware-derived setups).
|
||||
out := make([]clientView, 0)
|
||||
byID := map[string]int{}
|
||||
|
||||
// 1) Currently-connected clients: authoritative; takes precedence.
|
||||
for _, c := range h.srv.Clients() {
|
||||
byID[c.ID] = len(out)
|
||||
out = append(out, clientView{
|
||||
ClientID: c.ID,
|
||||
Name: c.Name,
|
||||
Connected: true,
|
||||
LastSeen: time.Now(),
|
||||
Source: "connected",
|
||||
})
|
||||
}
|
||||
|
||||
// 2) Roster: every client_id we've seen via hello, even if rejected.
|
||||
for _, e := range h.roster.List() {
|
||||
if _, ok := byID[e.ClientID]; ok {
|
||||
continue
|
||||
}
|
||||
byID[e.ClientID] = len(out)
|
||||
out = append(out, clientView{
|
||||
ClientID: e.ClientID,
|
||||
Name: e.Name,
|
||||
Connected: false,
|
||||
LastSeen: e.LastSeen,
|
||||
Source: "roster",
|
||||
})
|
||||
}
|
||||
|
||||
// 3) mDNS-only entries: never handshaked, so we don't know
|
||||
// client_id. Useful for "the speaker is on the network but
|
||||
// something is wrong" diagnostics; not directly usable for
|
||||
// preset membership.
|
||||
for _, d := range h.disc.List() {
|
||||
out = append(out, clientView{
|
||||
Instance: d.Instance,
|
||||
Name: d.Instance,
|
||||
LastSeen: d.LastSeen,
|
||||
Source: "mdns",
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type sourceView struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsMonitor bool `json:"is_monitor"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) listSources(w http.ResponseWriter, _ *http.Request) {
|
||||
targets, err := pipewire.ListCaptureTargets()
|
||||
if err != nil {
|
||||
httpError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]sourceView, 0, len(targets))
|
||||
for _, t := range targets {
|
||||
out = append(out, sourceView{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
IsMonitor: t.IsMonitor,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
type stateView struct {
|
||||
ActivePreset string `json:"active_preset"`
|
||||
Playback Playback `json:"playback"`
|
||||
Volume int `json:"volume"`
|
||||
Members []MemberState `json:"members"`
|
||||
}
|
||||
|
||||
func (h *HTTPServer) getState(w http.ResponseWriter, _ *http.Request) {
|
||||
active, pb, vol, members := h.mgr.State()
|
||||
writeJSON(w, http.StatusOK, stateView{
|
||||
ActivePreset: active,
|
||||
Playback: pb,
|
||||
Volume: vol,
|
||||
Members: members,
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func httpError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// MapManagerError converts manager sentinel errors to HTTP status codes.
|
||||
// Currently unused by the HTTP layer (handlers use sentinel directly),
|
||||
// but exported for potential use by the MQTT bridge command path.
|
||||
func MapManagerError(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, errNotFound):
|
||||
return http.StatusNotFound
|
||||
case errors.Is(err, errBadRequest), errors.Is(err, errNoActivePreset):
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user