307 lines
8.3 KiB
Go
307 lines
8.3 KiB
Go
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()
|
|
// Fill in names for offline members from the roster — the manager
|
|
// only knows names of currently-connected clients.
|
|
if len(members) > 0 {
|
|
roster := map[string]string{}
|
|
for _, e := range h.roster.List() {
|
|
roster[e.ClientID] = e.Name
|
|
}
|
|
for i := range members {
|
|
if members[i].Name == "" {
|
|
members[i].Name = roster[members[i].ClientID]
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|