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
- 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>
292 lines
13 KiB
Go
292 lines
13 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/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)
|
||
}
|