diff --git a/internal/api/admin_reacquisition.go b/internal/api/admin_reacquisition.go new file mode 100644 index 00000000..b4f3b30a --- /dev/null +++ b/internal/api/admin_reacquisition.go @@ -0,0 +1,88 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/reacquisition" +) + +// reacquisitionSettingsResp is the admin card's payload (milestone #290). +// +// Carries more than the stored settings: [UnnameableAlbums] is the count of +// albums with missing files that can never be auto-requested because neither +// they nor their artist has an MBID. The card states that number rather than +// leaving the operator to wonder why some rows never get a request — a +// feature that silently does nothing for part of its input reads as broken. +type reacquisitionSettingsResp struct { + Enabled bool `json:"enabled"` + GraceHours int32 `json:"grace_hours"` + BackoffBaseHours int32 `json:"backoff_base_hours"` + BackoffMaxHours int32 `json:"backoff_max_hours"` + MaxAttempts int32 `json:"max_attempts"` + MaxPerPass int32 `json:"max_per_pass"` + AutoApprove bool `json:"auto_approve"` + + UnnameableAlbums int64 `json:"unnameable_albums"` +} + +// handleGetReacquisitionSettings implements GET /api/admin/library/reacquisition. +func (h *handlers) handleGetReacquisitionSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.reacquisitionPayload(r)) +} + +// handleUpdateReacquisitionSettings implements PUT /api/admin/library/reacquisition. +func (h *handlers) handleUpdateReacquisitionSettings(w http.ResponseWriter, r *http.Request) { + var req reacquisitionSettingsResp + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON")) + return + } + _, err := h.reacqSettings.Set(r.Context(), reacquisition.Settings{ + Enabled: req.Enabled, + GraceHours: req.GraceHours, + BackoffBaseHours: req.BackoffBaseHours, + BackoffMaxHours: req.BackoffMaxHours, + MaxAttempts: req.MaxAttempts, + MaxPerPass: req.MaxPerPass, + AutoApprove: req.AutoApprove, + }) + if err != nil { + // The Go-side validation mirrors migration 0056's CHECKs so the + // operator gets a readable message naming the field, rather than a + // constraint-violation string leaking through as a 500. + if errors.Is(err, reacquisition.ErrOutOfRange) { + writeErr(w, apierror.BadRequest("invalid_setting", err.Error())) + return + } + writeErrWithLog(w, h.logger, "admin reacquisition: update failed", apierror.Internal(err)) + return + } + // Echo the payload recomputed under the new values so the card reflects + // what it just did without a reload. + writeJSON(w, http.StatusOK, h.reacquisitionPayload(r)) +} + +func (h *handlers) reacquisitionPayload(r *http.Request) reacquisitionSettingsResp { + cur := h.reacqSettings.Get() + out := reacquisitionSettingsResp{ + Enabled: cur.Enabled, + GraceHours: cur.GraceHours, + BackoffBaseHours: cur.BackoffBaseHours, + BackoffMaxHours: cur.BackoffMaxHours, + MaxAttempts: cur.MaxAttempts, + MaxPerPass: cur.MaxPerPass, + AutoApprove: cur.AutoApprove, + } + // Best-effort: the settings are the point of this endpoint, and failing + // the whole card because a count query hiccuped would be the wrong trade. + if n, err := dbq.New(h.pool).CountAlbumsMissingWithoutMbid(r.Context()); err == nil { + out.UnnameableAlbums = n + } else { + h.logger.Warn("admin reacquisition: unnameable count failed", "err", err) + } + return out +} diff --git a/internal/api/api.go b/internal/api/api.go index 5c04caa7..d9d99990 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -23,6 +23,7 @@ import ( "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/tags" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" @@ -31,7 +32,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -53,6 +54,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev playlistScheduler: playlistScheduler, streamSecret: streamSecret, netSettings: netSettings, + reacqSettings: reacqSettings, } r.Route("/api", func(api chi.Router) { @@ -195,6 +197,13 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/network-settings", h.handleGetNetworkSettings) admin.Put("/network-settings", h.handleUpdateNetworkSettings) + // Policy for turning a missing file back into a Lidarr + // request (#290). Sits beside the missing-files list it + // governs rather than under /lidarr, because the operator + // meets it on the missing-files surface. + admin.Get("/library/reacquisition", h.handleGetReacquisitionSettings) + admin.Put("/library/reacquisition", h.handleUpdateReacquisitionSettings) + admin.Get("/scan/status", h.handleGetScanStatus) admin.Post("/scan/run", h.handleTriggerScan) // Sits under /library rather than /tracks because what it @@ -279,6 +288,10 @@ type handlers struct { mailer mailer.Sender eventbus *eventbus.Bus playlistScheduler *playlists.Scheduler + // reacqSettings is the DB-backed policy for auto re-acquisition of + // missing files (milestone #290) — grace window, backoff, attempt caps. + // Cached in the service, so the admin card reads it without a query. + reacqSettings *reacquisition.SettingsService // netSettings caches the trusted reverse-proxy depth read by the auth // middleware on every request and edited from the admin network card. netSettings *netsettings.Service diff --git a/internal/server/server.go b/internal/server/server.go index d0dede52..622e2a9b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -28,6 +28,7 @@ import ( "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" @@ -151,6 +152,14 @@ func (s *Server) Router() http.Handler { 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) @@ -177,7 +186,7 @@ func (s *Server) Router() http.Handler { 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) + 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