feat(api): admin endpoints for the re-acquisition policy — #2527
GET/PUT /api/admin/library/reacquisition, so every knob the sweeper reads is editable without a restart (rule #25). Routed under /library beside the missing-files list it governs rather than under /lidarr: Lidarr is the mechanism, but missing files are the problem the operator came to solve, and that is the surface they meet it on. The payload carries one thing the settings table doesn't: the count of albums with missing files that can never be auto-requested, because neither they nor their artist has an MBID. Nothing can be asked of Lidarr for a release MusicBrainz cannot name, and a feature that silently does nothing for part of its input reads as broken -- so the card states the number instead of leaving it to be inferred. Counted best-effort: the settings are the point of the endpoint, and failing the whole card because a count query hiccuped would be the wrong trade. Range errors come back as 400 naming the field. The Go-side validation mirrors migration 0056's CHECKs precisely so the operator reads "grace_hours must be 1-720" rather than a constraint-violation string surfacing as a 500.
This commit is contained in:
@@ -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
|
||||
}
|
||||
+14
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user