GET/PUT /api/admin/library/reacquisition, so every knob the sweeper reads is editable without a restart (rule #25). Routed under /library beside the missing-files list it governs rather than under /lidarr: Lidarr is the mechanism, but missing files are the problem the operator came to solve, and that is the surface they meet it on. The payload carries one thing the settings table doesn't: the count of albums with missing files that can never be auto-requested, because neither they nor their artist has an MBID. Nothing can be asked of Lidarr for a release MusicBrainz cannot name, and a feature that silently does nothing for part of its input reads as broken -- so the card states the number instead of leaving it to be inferred. Counted best-effort: the settings are the point of the endpoint, and failing the whole card because a count query hiccuped would be the wrong trade. Range errors come back as 400 naming the field. The Go-side validation mirrors migration 0056's CHECKs precisely so the operator reads "grace_hours must be 1-720" rather than a constraint-violation string surfacing as a 500.
240 lines
11 KiB
Go
240 lines
11 KiB
Go
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/netsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
"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 <DataDir>/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
|
|
TagSettings *tags.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()
|
|
|
|
// Built before the router because the access log needs it, and the access
|
|
// log covers /healthz and the SPA — which exist whether or not there's a
|
|
// pool. netsettings.New handles a nil pool by returning a default-valued
|
|
// service, so this needs no branch and no later reassignment; hoisting it
|
|
// here keeps the accessor a plain method value instead of a closure over
|
|
// a variable mutated after the middleware is already registered.
|
|
netSettings, nsErr := netsettings.New(context.Background(), s.Pool, s.Logger)
|
|
if nsErr != nil {
|
|
s.Logger.Error("server: netsettings boot failed, using default hops", "err", nsErr)
|
|
}
|
|
|
|
r.Use(middleware.RequestID)
|
|
r.Use(requestLog(s.Logger, netSettings.Hops))
|
|
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)
|
|
// Always usable even when the load fails — it falls back to the
|
|
// shipped defaults rather than leaving the admin card unable to
|
|
// render (same posture as netsettings above).
|
|
reacqSettings, raErr := reacquisition.NewSettingsService(
|
|
context.Background(), s.Pool, s.Logger)
|
|
if raErr != nil {
|
|
s.Logger.Warn("reacquisition settings unavailable; serving defaults", "err", raErr)
|
|
}
|
|
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.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings)
|
|
// /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, netSettings.Hops), 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)
|
|
}
|