Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -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