test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.
One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.
Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.
The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.
DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
241 lines
11 KiB
Go
241 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, 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 / 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,
|
|
"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)
|
|
}
|