Files
minstrel/internal/server/server.go
T
bvandeusenandClaude Opus 5.5 7e1cfb8d08
release / govulncheck (push) Successful in 14s
release / web (push) Failing after 16s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m54s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: Integrations splits into Library, Acquisition, Metadata sources and Email & network, each a page on the admin rail; the Overview keeps only what needs you (M503 #5607)
- Library: the folders, Run scan and a row per scan job, cover art, fingerprints and loudness (off the Overview and Duplicates)
- Acquisition: indexers and clients, searching, re-acquisition (off Missing files), Lidarr
- Metadata sources: AcoustID, and one provider block for cover art and tags in place of the pasted pair
- Email & network: outgoing email, the notification schedule, public address, proxies
- Lidarr, outgoing email and the provider block are rebuilt as SettingsGroup rows; failures read as sentences, not codes
- Duplicates and Missing files say what their setting is and link to where it is set

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:21:48 -04:00

292 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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/requests"
"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
// FingerprintSettings is the DB-backed fingerprinting policy (M400 #3913).
// Constructed in cmd/minstrel/main.go and shared with the scanner and the
// fingerprint workers, so a save from the admin card reaches them without a
// restart. Router() constructs a fallback when nil (tests).
FingerprintSettings *library.FingerprintSettingsService
// LoudnessSettings is the DB-backed loudness analysis policy (M464 #4995),
// shared with the loudness backfill. Nil makes the router load its own.
LoudnessSettings *library.LoudnessSettingsService
// AcoustIDLookup is the AcoustID lookup worker (M401), whose settings,
// status and "look up now" the admin card drives. Nil in test contexts;
// one is built (and never run) so the admin routes still answer.
AcoustIDLookup *library.AcoustIDLookupWorker
// ReacqSettings is the DB-backed missing-file re-acquisition policy
// (milestone #290) — the same instance the sweeper in cmd/minstrel/main.go
// reads, so a save from the admin card reaches it without a restart
// (#3936). Router() constructs a fallback when nil (tests).
ReacqSettings *reacquisition.SettingsService
// 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.Use(securityHeaders(netSettings.Hops))
r.Use(limitRequestBody)
r.Use(requireJSONForCookieWrites)
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 › Acquisition 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)
}
requestSvc := requests.NewService(s.Pool)
reacqSettings := s.ReacqSettings
if reacqSettings == nil {
// Test contexts construct Server without main.go's boot wiring.
// 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).
var raErr error
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, s.DataDir)
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 / requests / 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)
}
}
fpSettings := s.FingerprintSettings
if fpSettings == nil {
// Test contexts construct Server without main.go's wiring. Always
// usable: a failed load serves the defaults.
var err error
fpSettings, err = library.NewFingerprintSettingsService(context.Background(), s.Pool)
if err != nil {
s.Logger.Warn("fingerprint settings unavailable; serving defaults", "err", err)
}
}
loudSettings := s.LoudnessSettings
if loudSettings == nil {
var err error
loudSettings, err = library.NewLoudnessSettingsService(context.Background(), s.Pool)
if err != nil {
s.Logger.Warn("loudness settings unavailable; serving defaults", "err", err)
}
}
acoustIDLookup := s.AcoustIDLookup
if acoustIDLookup == nil {
acoustIDSettings, err := library.NewAcoustIDSettingsService(context.Background(), s.Pool)
if err != nil {
s.Logger.Warn("acoustid settings unavailable; serving defaults", "err", err)
}
acoustIDLookup = library.NewAcoustIDLookupWorker(s.Pool, s.Logger, acoustIDSettings, nil)
}
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, requestSvc, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret, netSettings, reacqSettings, fpSettings, loudSettings, acoustIDLookup)
// /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, netSettings.Hops)
}
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,
"channel": ServerChannel,
"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)
}