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(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) r.Route("/api/admin", func(admin chi.Router) { admin.Use(auth.RequireUser(s.Pool)) admin.Use(auth.RequireAdmin()) if s.Scanner != nil { admin.Post("/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"}) } // 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) }