release / govulncheck (push) Successful in 16s
release / go (push) Failing after 49s
release / web (push) Successful in 1m9s
release / integration (push) Failing after 4m14s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 4m38s
release / Build signed APK (push) Canceled after 4m38s
A one-off end-to-end run of the :dev image (scratch Postgres, generated FLAC files, no outbound network) found that the web restore, done the way docs/hosting.md describes, was refused with not_empty: a new server's startup scan fills the library before anyone signs up, and Restore counted tracks as occupied. The command-line restore passed the same run. - Restore now asks only that the instance has no accounts. The library the first scan found is replaced by the backup's (every foreign key into artists/albums/tracks cascades or sets null), and the restore locks the library tables so a running scan waits rather than adding to them. - Settings are read once at boot by about ten services, the library folders among them, so after a web restore the server ran on its defaults until restarted. The restore endpoint now answers, then asks the process to restart in place (syscall.Exec of the same binary, arguments and environment), which needs no container restart policy. The startup scan of the restarted server replaces the scan the endpoint used to start. - The restore form says Minstrel is restarting; the docs say a scanned new server still takes a backup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
12 KiB
Go
266 lines
12 KiB
Go
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/backup"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
|
"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/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/quarantine"
|
|
"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"
|
|
)
|
|
|
|
// 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
|
|
// request 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. 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:
|
|
// 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.
|
|
// 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,
|
|
// shared with the loudness backfill. Nil makes the router load its own.
|
|
LoudnessSettings *library.LoudnessSettingsService
|
|
// AcoustIDLookup is the AcoustID lookup worker, 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 — the
|
|
// same instance the sweeper in cmd/minstrel/main.go reads, so a save from
|
|
// the admin card reaches it without a restart. Router() constructs a
|
|
// fallback when nil (tests).
|
|
ReacqSettings *reacquisition.SettingsService
|
|
// Backups is the server's own backup, the instance whose loop runs the
|
|
// nightly schedule. Nil builds one that runs only when an admin asks.
|
|
Backups *backup.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
|
|
// Restart asks the process to stop and start again in place. A web
|
|
// restore calls it so the restored settings are read at boot.
|
|
Restart func()
|
|
}
|
|
|
|
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,
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
quarantineSvc := quarantine.NewService(s.Pool, s.DataDir)
|
|
tracksSvc := tracks.NewService(s.Pool, s.Logger, 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, one per process: producers in
|
|
// playevents / requests / scanner publish into the same instance.
|
|
// Background workers are built in cmd/minstrel/main.go before Router()
|
|
// runs and share s.Bus. With no bus set (tests), a fresh local one is
|
|
// used.
|
|
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)
|
|
}
|
|
backups := s.Backups
|
|
if backups == nil {
|
|
backups = backup.NewService(s.Pool, s.Logger, buildinfo.Version)
|
|
}
|
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, requestSvc, quarantineSvc, 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, backups, s.Restart)
|
|
// /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": buildinfo.Version,
|
|
"channel": buildinfo.Channel,
|
|
"min_client_version": MinClientVersion,
|
|
})
|
|
}
|
|
|
|
// handleAdminScan runs a scan synchronously and returns the resulting stats.
|
|
// Synchronous, so the caller can tell when it's done; the scan's progress is
|
|
// also published as scan-run events.
|
|
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)
|
|
}
|