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/netsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"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/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
@@ -31,7 +32,7 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
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,
|
playlistScheduler: playlistScheduler,
|
||||||
streamSecret: streamSecret,
|
streamSecret: streamSecret,
|
||||||
netSettings: netSettings,
|
netSettings: netSettings,
|
||||||
|
reacqSettings: reacqSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
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.Get("/network-settings", h.handleGetNetworkSettings)
|
||||||
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
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.Get("/scan/status", h.handleGetScanStatus)
|
||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
// Sits under /library rather than /tracks because what it
|
// Sits under /library rather than /tracks because what it
|
||||||
@@ -279,6 +288,10 @@ type handlers struct {
|
|||||||
mailer mailer.Sender
|
mailer mailer.Sender
|
||||||
eventbus *eventbus.Bus
|
eventbus *eventbus.Bus
|
||||||
playlistScheduler *playlists.Scheduler
|
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
|
// netSettings caches the trusted reverse-proxy depth read by the auth
|
||||||
// middleware on every request and edited from the admin network card.
|
// middleware on every request and edited from the admin network card.
|
||||||
netSettings *netsettings.Service
|
netSettings *netsettings.Service
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"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/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
@@ -151,6 +152,14 @@ func (s *Server) Router() http.Handler {
|
|||||||
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
return lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||||||
}
|
}
|
||||||
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
|
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)
|
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
||||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
||||||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, 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)
|
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
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
Reference in New Issue
Block a user