From fced88a8a6dfcbaff49fa62ef08b3c26203e15d4 Mon Sep 17 00:00:00 2001 From: Tim Van Wassenhove Date: Thu, 19 Feb 2026 12:13:36 +0100 Subject: [PATCH] feat: add management API endpoints matching ueberboese-app --- cmd/soundtouch-service/main.go | 11 ++ pkg/service/handlers/handlers_mgmt.go | 215 ++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 pkg/service/handlers/handlers_mgmt.go diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index f637e07..bf96ae4 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -667,6 +667,17 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Post("/error", server.HandleErrorStats) }) + r.Route("/mgmt", func(r chi.Router) { + r.Use(server.BasicAuthMgmt()) + r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers) + r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents) + r.Post("/spotify/init", server.HandleMgmtSpotifyInit) + r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm) + r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts) + r.Get("/spotify/token", server.HandleMgmtSpotifyToken) + r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity) + }) + r.Get("/proxy/*", server.HandleProxyRequest) r.Route("/setup", func(r chi.Router) { diff --git a/pkg/service/handlers/handlers_mgmt.go b/pkg/service/handlers/handlers_mgmt.go new file mode 100644 index 0000000..fe3e242 --- /dev/null +++ b/pkg/service/handlers/handlers_mgmt.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "encoding/json" + "io" + "log" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// BasicAuthMgmt returns a Basic Auth middleware using the server's management credentials. +func (s *Server) BasicAuthMgmt() func(http.Handler) http.Handler { + s.mu.RLock() + username := s.mgmtUsername + password := s.mgmtPassword + s.mu.RUnlock() + + return BasicAuthMiddleware(username, password) +} + +// HandleMgmtListSpeakers returns discovered speakers for the given account. +func (s *Server) HandleMgmtListSpeakers(w http.ResponseWriter, r *http.Request) { + _ = chi.URLParam(r, "accountId") + + allDevices, err := s.ds.ListAllDevices() + if err != nil { + log.Printf("[Mgmt] Failed to list devices: %v", err) + allDevices = nil + } + + type speaker struct { + IPAddress string `json:"ipAddress"` + Name string `json:"name"` + DeviceID string `json:"deviceId"` + Type string `json:"type"` + } + + speakers := make([]speaker, 0, len(allDevices)) + for _, d := range allDevices { + speakers = append(speakers, speaker{ + IPAddress: d.IPAddress, + Name: d.Name, + DeviceID: d.DeviceID, + Type: d.ProductCode, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "speakers": speakers, + }) +} + +// HandleMgmtDeviceEvents returns events for a device (currently a placeholder). +func (s *Server) HandleMgmtDeviceEvents(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + + events := s.ds.GetDeviceEvents(deviceID) + if events == nil { + events = nil // will marshal as empty array via wrapper + } + + w.Header().Set("Content-Type", "application/json") + // Return the events in the structure the Flutter app expects. + // Use an explicit empty slice to ensure JSON "[]" instead of "null". + type eventEntry struct { + Type string `json:"type"` + Time string `json:"time"` + Data map[string]interface{} `json:"data"` + } + + result := make([]eventEntry, 0, len(events)) + for _, e := range events { + result = append(result, eventEntry{ + Type: e.Type, + Time: e.Time, + Data: e.Data, + }) + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "events": result, + }) +} + +// HandleMgmtSpotifyInit starts the Spotify OAuth flow by returning an authorization URL. +func (s *Server) HandleMgmtSpotifyInit(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable) + return + } + + redirectURL := svc.BuildAuthorizeURL() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "redirectUrl": redirectURL, + }) +} + +// HandleMgmtSpotifyConfirm exchanges an authorization code for tokens. +func (s *Server) HandleMgmtSpotifyConfirm(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable) + return + } + + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, `{"error":"missing code parameter"}`, http.StatusBadRequest) + return + } + + if err := svc.ExchangeCodeAndStore(code); err != nil { + log.Printf("[Mgmt] Spotify confirm failed: %v", err) + http.Error(w, `{"error":"token exchange failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) +} + +// HandleMgmtSpotifyAccounts returns linked Spotify accounts (tokens stripped). +func (s *Server) HandleMgmtSpotifyAccounts(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable) + return + } + + accounts := svc.GetAccounts() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "accounts": accounts, + }) +} + +// HandleMgmtSpotifyToken returns a fresh Spotify access token and username. +func (s *Server) HandleMgmtSpotifyToken(w http.ResponseWriter, _ *http.Request) { + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable) + return + } + + accessToken, username, err := svc.GetFreshToken() + if err != nil { + log.Printf("[Mgmt] Spotify token error: %v", err) + http.Error(w, `{"error":"no token available"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": accessToken, + "username": username, + }) +} + +// HandleMgmtSpotifyEntity resolves a Spotify URI to name and image URL. +func (s *Server) HandleMgmtSpotifyEntity(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + svc := s.spotifyService + s.mu.RUnlock() + + if svc == nil { + http.Error(w, `{"error":"spotify not configured"}`, http.StatusServiceUnavailable) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, `{"error":"failed to read body"}`, http.StatusBadRequest) + return + } + + var req struct { + URI string `json:"uri"` + } + if err := json.Unmarshal(body, &req); err != nil || req.URI == "" { + http.Error(w, `{"error":"missing or invalid uri"}`, http.StatusBadRequest) + return + } + + name, imageURL, err := svc.ResolveEntity(req.URI) + if err != nil { + log.Printf("[Mgmt] Spotify entity resolve error: %v", err) + http.Error(w, `{"error":"entity resolution failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "name": name, + "imageUrl": imageURL, + }) +}