package server import ( "context" "encoding/json" "errors" "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/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/eventbus" "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/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/web" ) // lidarrUnmonitorAdapter wires the per-call lidarrClientFn factory pattern // (used elsewhere in this package so admin Lidarr config edits take effect // without restart) into the tracks.LidarrUnmonitorer interface that // internal/tracks expects. When the factory returns nil (Lidarr disabled) // we surface a sentinel error — tracks.Service treats UnmonitorTrack // failures as non-fatal, so this collapses to a `lidarr_unmonitor_failed` // flag in the response and the destructive part still runs. type lidarrUnmonitorAdapter struct { fn func() *lidarr.Client } var errLidarrDisabled = errors.New("lidarr disabled") func (a lidarrUnmonitorAdapter) UnmonitorTrack(ctx context.Context, trackMbid, albumMbid string) error { c := a.fn() if c == nil { return errLidarrDisabled } return c.UnmonitorTrack(ctx, trackMbid, albumMbid) } // ScanTrigger is the subset of the scanner the HTTP handler needs. Kept as an // interface so tests can stub it without touching the DB. The progressCb // parameter (added in m7-scan-progress) lets the orchestrator drive partial- // tally writes; the HTTP handler passes nil for fire-and-forget triggers. type ScanTrigger interface { Scan(ctx context.Context, progressCb func(library.Stats)) (library.Stats, error) } type Server struct { Logger *slog.Logger Pool *pgxpool.Pool Scanner ScanTrigger SubsonicCfg subsonic.Config EventsCfg config.EventsConfig RecommendationCfg config.RecommendationConfig // DataDir is the on-disk root for cached artifacts (currently // playlist cover collages under /playlist_covers/). Empty // strings are tolerated by tests that don't exercise persisted-cover // codepaths; production callers should pass a writable directory. DataDir string BrandingCfg config.BrandingConfig CoverEnricher *coverart.Enricher CoverSettings *coverart.SettingsService LibraryScanner *library.Scanner ScanCfg library.RunScanConfig // Bus is the live-event bus shared with background workers (the // lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go. // When nil, Router() constructs a local fallback (test contexts). Bus *eventbus.Bus // PlaylistScheduler fires per-user daily system-playlist builds at // 03:00 in each user's stored timezone (#392 Half B). Constructed // in cmd/minstrel/main.go and threaded into the API handlers so // PUT /api/me/timezone and POST /api/auth/register can call // Refresh synchronously. PlaylistScheduler *playlists.Scheduler // RecSettings is the DB-backed recommendation tuning lab (#1250): // scoring-weight profiles + taste-build knobs. Constructed in // cmd/minstrel/main.go (it pushes daily-mix weights into package // playlists at boot); the API layer reads radio weights per request // and serves the admin tuning endpoints from it. Router() constructs // a fallback when nil (tests). RecSettings *recsettings.Service // StreamSecret is the HMAC key used by /api/cast/stream-token to // mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream // to verify them. Sourced from config.Config.StreamSecret. Tests that // leave it nil leave the cookie path intact and reject all signed // tokens (HMAC of empty key matches nothing a client could mint). StreamSecret []byte } func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server { return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg} } 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) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir) playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir) smtpSender := mailer.NewSMTPSender(s.Pool, s.Logger.With("component", "mailer")) // Live-event bus for SSE subscribers (#392). Constructed per-process; // producers in playevents / lidarrrequests / scanner publish into // the same instance. Background workers (lidarr reconciler, scan // scheduler) live in cmd/minstrel/main.go where they're constructed // before Router() runs; they read s.Bus directly so they share this // process's bus. When Router() runs before main set the bus (tests, // or future contexts) we fall back to a fresh local instance. bus := s.Bus if bus == nil { bus = eventbus.New() } recSettings := s.RecSettings if recSettings == nil { // Test contexts construct Server directly without main.go's // boot wiring; reconcile here so radio + the admin tuning // endpoints work against the same pool. var err error recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger) if err != nil { s.Logger.Error("server: recsettings boot failed", "err", err) } } api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) // /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(s.BrandingCfg) 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", "version": ServerVersion, "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(), nil) 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) }