Files
minstrel/internal/server/server.go
T
bvandeusen b03da903d9 feat(server): /healthz returns min_client_version
Lets the M7 Flutter client refuse to operate against an incompatible
server (e.g., new endpoints the client depends on, breaking schema
shifts). The version constant is bumped on each release where the
client/server contract changes; old clients show the user a blocking
"update required" modal pointed at the latest APK / TestFlight build.
2026-05-02 15:11:10 -04:00

127 lines
4.6 KiB
Go

package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/api"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
"git.fabledsword.com/bvandeusen/minstrel/web"
)
// ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an
// interface so tests can stub it without touching the DB.
type ScanTrigger interface {
Scan(ctx context.Context) (library.Stats, error)
}
type Server struct {
Logger *slog.Logger
Pool *pgxpool.Pool
Scanner ScanTrigger
SubsonicCfg subsonic.Config
EventsCfg config.EventsConfig
RecommendationCfg config.RecommendationConfig
}
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg}
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(requestLog(s.Logger))
r.Use(middleware.Recoverer)
r.Get("/healthz", s.handleHealthz)
if s.Pool != nil {
writer := playevents.NewWriter(
s.Pool, s.Logger,
time.Duration(s.EventsCfg.SessionTimeoutMinutes)*time.Minute,
s.EventsCfg.SkipMaxCompletionRatio,
s.EventsCfg.SkipMaxDurationPlayedMs,
)
lidarrCfg := lidarrconfig.New(s.Pool)
// Per-call client factory: re-reads config so an admin save in
// /admin/integrations takes effect immediately without restart.
// Returns nil when Lidarr is disabled, which the Service-layer
// methods translate to ErrLidarrDisabled.
lidarrClientFn := func() *lidarr.Client {
cfg, err := lidarrCfg.Get(context.Background())
if err != nil || !cfg.Enabled || cfg.BaseURL == "" || cfg.APIKey == "" {
return nil
}
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
}
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar)
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
// subtree that shadows every admin route registered by api.Mount.
if s.Scanner != nil {
r.With(auth.RequireUser(s.Pool), auth.RequireAdmin()).
Post("/api/admin/scan", s.handleAdminScan)
}
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
}
spa := web.Handler()
r.NotFound(func(w http.ResponseWriter, req *http.Request) {
p := req.URL.Path
if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/rest/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"not found"}}`))
return
}
spa.ServeHTTP(w, req)
})
return r
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"min_client_version": MinClientVersion,
})
}
// handleAdminScan runs a scan synchronously and returns the resulting stats.
// Kept synchronous for v1: libraries are small and the operator can tell when
// it's done. Async jobs with status polling are a later-milestone concern.
func (s *Server) handleAdminScan(w http.ResponseWriter, r *http.Request) {
stats, err := s.Scanner.Scan(r.Context())
w.Header().Set("Content-Type", "application/json")
if err != nil {
s.Logger.Error("admin scan failed", "err", err)
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error(), "stats": stats})
return
}
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(stats)
}