Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126

Merged
bvandeusen merged 23 commits from dev into main 2026-08-17 16:28:14 -04:00
3 changed files with 112 additions and 2 deletions
Showing only changes of commit 30a5ac56ce - Show all commits
+88
View File
@@ -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
View File
@@ -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
+10 -1
View File
@@ -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