feat(coverart): #388 remove global cover-art backfill cap

Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.

- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
  EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
  disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
  MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
  server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
  unbounded; response {queued:int} → {started:bool} (the count is
  unknowable synchronously for a fire-and-forget drain). Web copy +
  client type + Go/web tests updated to match.

No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 18:41:21 -04:00
parent 4fca0e66cb
commit 005965d6de
14 changed files with 107 additions and 93 deletions
+7 -7
View File
@@ -105,8 +105,8 @@ func run() error {
logger.With("component", "scan_run"), logger.With("component", "scan_run"),
library.RunScanConfig{ library.RunScanConfig{
BackfillCap: 5000, BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap, EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir, DataDir: cfg.Storage.DataDir,
}, },
); err != nil { ); err != nil {
@@ -122,8 +122,8 @@ func run() error {
logger.With("component", "scan_run"), logger.With("component", "scan_run"),
library.RunScanConfig{ library.RunScanConfig{
BackfillCap: 5000, BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap, EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir, DataDir: cfg.Storage.DataDir,
}, },
); err != nil { ); err != nil {
@@ -194,8 +194,8 @@ func run() error {
scanCfg := library.RunScanConfig{ scanCfg := library.RunScanConfig{
BackfillCap: 5000, BackfillCap: 5000,
EnrichCap: cfg.Library.CoverArtBackfillCap, EnrichCap: -1, // #388: no global cap; per-provider rate limits
ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, ArtistEnrichCap: -1,
DataDir: cfg.Storage.DataDir, DataDir: cfg.Storage.DataDir,
} }
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"), scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
@@ -203,7 +203,7 @@ func run() error {
scheduler.Start(ctx) scheduler.Start(ctx)
srv := server.New(logger, pool, scanner, subsonic.Config{ srv := server.New(logger, pool, scanner, subsonic.Config{
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg, scheduler) }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
srv.Bus = bus srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler srv.PlaylistScheduler = playlistScheduler
httpServer := &http.Server{ httpServer := &http.Server{
+7 -6
View File
@@ -48,19 +48,20 @@ func (h *handlers) handleAdminAlbumRefetchCover(w http.ResponseWriter, r *http.R
} }
type adminBulkRefetchResp struct { type adminBulkRefetchResp struct {
Queued int `json:"queued"` Started bool `json:"started"`
} }
// handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing. // handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing.
// Returns immediately; actual drain runs in a background goroutine bounded by // Returns immediately; the drain runs UNBOUNDED in a background goroutine
// the configured backfill cap. // (#388 — no global cap; remote providers self-throttle per provider, local
// sources run at disk speed). The count is unknowable synchronously, so the
// response just acknowledges the drain started.
func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) { func (h *handlers) handleAdminBulkRefetchCovers(w http.ResponseWriter, _ *http.Request) {
cap := h.coverArtBackfillCap
go func() { go func() {
bgCtx := context.Background() bgCtx := context.Background()
if _, err := h.coverart.EnrichRetryMissing(bgCtx, cap); err != nil { if _, err := h.coverart.EnrichRetryMissing(bgCtx, -1); err != nil {
h.logger.Warn("admin: bulk cover refetch failed", "err", err) h.logger.Warn("admin: bulk cover refetch failed", "err", err)
} }
}() }()
writeJSON(w, http.StatusOK, adminBulkRefetchResp{Queued: cap}) writeJSON(w, http.StatusOK, adminBulkRefetchResp{Started: true})
} }
+3 -4
View File
@@ -53,7 +53,6 @@ func testHandlersWithCovers(t *testing.T) (*handlers, *coverart.Enricher) {
} }
enricher := coverart.NewEnricher(pool, slog.Default(), settings) enricher := coverart.NewEnricher(pool, slog.Default(), settings)
h.coverart = enricher h.coverart = enricher
h.coverArtBackfillCap = 100
return h, enricher return h, enricher
} }
@@ -97,7 +96,7 @@ func TestAdminAlbumRefetchCover_AdminOK(t *testing.T) {
} }
} }
func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) { func TestAdminBulkRefetchCovers_AdminStartsRefetch(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set") t.Skip("MINSTREL_TEST_DATABASE_URL not set")
} }
@@ -115,8 +114,8 @@ func TestAdminBulkRefetchCovers_AdminReturnsQueuedCount(t *testing.T) {
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
if resp.Queued != h.coverArtBackfillCap { if !resp.Started {
t.Errorf("queued = %d, want %d (cap)", resp.Queued, h.coverArtBackfillCap) t.Errorf("started = false, want true (bulk refetch should acknowledge)")
} }
} }
+35 -37
View File
@@ -28,26 +28,25 @@ 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, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) { func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
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,
rng: rng.Float64, rng: rng.Float64,
lidarrCfg: lidarrCfg, lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs, lidarrRequests: lidarrReqs,
lidarrQuarantine: lidarrQuar, lidarrQuarantine: lidarrQuar,
tracks: tracksSvc, tracks: tracksSvc,
playlists: playlistsSvc, playlists: playlistsSvc,
coverart: coverEnricher, coverart: coverEnricher,
coverArtBackfillCap: coverBackfillCap, coverSettings: coverSettings,
coverSettings: coverSettings, scanner: scanner,
scanner: scanner, scanCfg: scanCfg,
scanCfg: scanCfg, scheduler: scheduler,
scheduler: scheduler, dataDir: dataDir,
dataDir: dataDir, mailer: sender,
mailer: sender, eventbus: bus,
eventbus: bus, playlistScheduler: playlistScheduler,
playlistScheduler: playlistScheduler,
} }
r.Route("/api", func(api chi.Router) { r.Route("/api", func(api chi.Router) {
@@ -180,24 +179,23 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
} }
type handlers struct { type handlers struct {
pool *pgxpool.Pool pool *pgxpool.Pool
logger *slog.Logger logger *slog.Logger
events *playevents.Writer events *playevents.Writer
recCfg config.RecommendationConfig recCfg config.RecommendationConfig
rng func() float64 rng func() float64
lidarrCfg *lidarrconfig.Service lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service lidarrQuarantine *lidarrquarantine.Service
tracks *tracks.Service tracks *tracks.Service
playlists *playlists.Service playlists *playlists.Service
coverart *coverart.Enricher coverart *coverart.Enricher
coverArtBackfillCap int coverSettings *coverart.SettingsService
coverSettings *coverart.SettingsService scanner *library.Scanner
scanner *library.Scanner scanCfg library.RunScanConfig
scanCfg library.RunScanConfig scheduler *library.Scheduler
scheduler *library.Scheduler dataDir string
dataDir string mailer mailer.Sender
mailer mailer.Sender eventbus *eventbus.Bus
eventbus *eventbus.Bus playlistScheduler *playlists.Scheduler
playlistScheduler *playlists.Scheduler
} }
+1 -1
View File
@@ -444,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000) 30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverArtBackfillCap, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil) Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
paths := []string{ paths := []string{
"/api/artists", "/api/artists",
+5 -12
View File
@@ -56,11 +56,10 @@ type LogConfig struct {
} }
type LibraryConfig struct { type LibraryConfig struct {
ScanPaths []string `yaml:"scan_paths"` ScanPaths []string `yaml:"scan_paths"`
ScanOnStartup bool `yaml:"scan_on_startup"` ScanOnStartup bool `yaml:"scan_on_startup"`
CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true
CoverArtBackfillCap int `yaml:"coverart_backfill_cap"` // default 500 ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA
} }
// SubsonicConfig controls wire-format concessions on the /rest/* surface. // SubsonicConfig controls wire-format concessions on the /rest/* surface.
@@ -120,8 +119,7 @@ func Default() Config {
RadioSizeMax: 200, RadioSizeMax: 200,
}, },
Library: LibraryConfig{ Library: LibraryConfig{
CoverArtFromMBCAA: true, CoverArtFromMBCAA: true,
CoverArtBackfillCap: 500,
}, },
} }
} }
@@ -177,11 +175,6 @@ func applyEnv(cfg *Config) {
cfg.Library.CoverArtFromMBCAA = b cfg.Library.CoverArtFromMBCAA = b
} }
} }
if v, ok := os.LookupEnv("MINSTREL_LIBRARY_COVERART_BACKFILL_CAP"); ok {
if n, err := strconv.Atoi(v); err == nil {
cfg.Library.CoverArtBackfillCap = n
}
}
if v, ok := os.LookupEnv("MINSTREL_CONTACT_EMAIL"); ok { if v, ok := os.LookupEnv("MINSTREL_CONTACT_EMAIL"); ok {
cfg.Library.ContactEmail = v cfg.Library.ContactEmail = v
} }
+8 -2
View File
@@ -144,15 +144,21 @@ func (e *Enricher) recordArtistArt(ctx context.Context, artistID pgtype.UUID, th
// errored). The JSON tally written to scan_runs keeps the existing // errored). The JSON tally written to scan_runs keeps the existing
// processed/succeeded/failed shape for backwards compat with the // processed/succeeded/failed shape for backwards compat with the
// admin overview UI; the log line is the operator's diagnostic. // admin overview UI; the log line is the operator's diagnostic.
// limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (#388 —
// no global cap; remote providers self-throttle per provider).
func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string, func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) { progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit <= 0 { if limit == 0 {
return 0, 0, 0, nil return 0, 0, 0, nil
} }
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // unbounded
}
q := dbq.New(e.pool) q := dbq.New(e.pool)
rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{ rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{
ArtistArtSourcesVersion: e.settings.CurrentVersion(), ArtistArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit), Limit: queryLimit,
}) })
if qerr != nil { if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr) return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr)
+18 -4
View File
@@ -213,15 +213,23 @@ func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, pa
// JSON tally written to scan_runs keeps the existing // JSON tally written to scan_runs keeps the existing
// processed/succeeded/failed shape for backwards compat with the // processed/succeeded/failed shape for backwards compat with the
// admin overview UI; the log line is the operator's diagnostic. // admin overview UI; the log line is the operator's diagnostic.
// limit semantics: 0 = stage disabled (skip); >0 = bounded batch;
// <0 = unbounded — walk every album needing art. The global cap was
// removed (#388); external providers self-throttle via their
// per-provider httpClient MinInterval, local sources run at disk speed.
func (e *Enricher) EnrichBatch(ctx context.Context, limit int, func (e *Enricher) EnrichBatch(ctx context.Context, limit int,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) { progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit <= 0 { if limit == 0 {
return 0, 0, 0, nil return 0, 0, 0, nil
} }
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // unbounded
}
q := dbq.New(e.pool) q := dbq.New(e.pool)
rows, qerr := q.ListAlbumsMissingCover(ctx, dbq.ListAlbumsMissingCoverParams{ rows, qerr := q.ListAlbumsMissingCover(ctx, dbq.ListAlbumsMissingCoverParams{
CoverArtSourcesVersion: e.settings.CurrentVersion(), CoverArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit), Limit: queryLimit,
}) })
if qerr != nil { if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing: %w", qerr) return 0, 0, 0, fmt.Errorf("list missing: %w", qerr)
@@ -293,12 +301,18 @@ func (e *Enricher) logBatchSummary(stage string, eligible, processed, succeeded,
// EnrichRetryMissing drains both NULL and 'none' rows. Used by the // EnrichRetryMissing drains both NULL and 'none' rows. Used by the
// admin bulk-retry endpoint. Clears 'none' to NULL first so // admin bulk-retry endpoint. Clears 'none' to NULL first so
// EnrichAlbum picks them up. // EnrichAlbum picks them up.
// limit: 0 = no-op; >0 = bounded; <0 = unbounded (retry every
// missing/none album). Admin bulk-retry passes <0 post-#388.
func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) { func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) {
if limit <= 0 { if limit == 0 {
return 0, nil return 0, nil
} }
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1 // unbounded
}
q := dbq.New(e.pool) q := dbq.New(e.pool)
rows, err := q.ListAlbumsRetryMissing(ctx, int32(limit)) rows, err := q.ListAlbumsRetryMissing(ctx, queryLimit)
if err != nil { if err != nil {
return 0, fmt.Errorf("list retry missing: %w", err) return 0, fmt.Errorf("list retry missing: %w", err)
} }
+5 -3
View File
@@ -49,9 +49,11 @@ type ArtistArtEnrichStageTallies struct {
Failed int `json:"failed"` Failed int `json:"failed"`
} }
// RunScanConfig assembles the knobs for the orchestrator. BackfillCap and // RunScanConfig assembles the knobs for the orchestrator. BackfillCap is
// EnrichCap mirror the existing boot-time caps (5000 / coverArtBackfillCap) // the album/artist-MBID-backfill batch cap (5000); EnrichCap /
// so manual triggers don't accidentally drain the whole library. // ArtistEnrichCap gate cover/artist-art enrichment (0 = off, <0 =
// unbounded — #388 removed the global cover-art cap; remote providers
// self-throttle per provider).
type RunScanConfig struct { type RunScanConfig struct {
BackfillCap int BackfillCap int
EnrichCap int EnrichCap int
+10 -11
View File
@@ -72,14 +72,13 @@ type Server struct {
// playlist cover collages under <DataDir>/playlist_covers/). Empty // playlist cover collages under <DataDir>/playlist_covers/). Empty
// strings are tolerated by tests that don't exercise persisted-cover // strings are tolerated by tests that don't exercise persisted-cover
// codepaths; production callers should pass a writable directory. // codepaths; production callers should pass a writable directory.
DataDir string DataDir string
BrandingCfg config.BrandingConfig BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher CoverEnricher *coverart.Enricher
CoverArtBackfillCap int CoverSettings *coverart.SettingsService
CoverSettings *coverart.SettingsService LibraryScanner *library.Scanner
LibraryScanner *library.Scanner ScanCfg library.RunScanConfig
ScanCfg library.RunScanConfig Scheduler *library.Scheduler
Scheduler *library.Scheduler
// Bus is the live-event bus shared with background workers (the // Bus is the live-event bus shared with background workers (the
// lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go. // lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go.
// When nil, Router() constructs a local fallback (test contexts). // When nil, Router() constructs a local fallback (test contexts).
@@ -92,8 +91,8 @@ type Server struct {
PlaylistScheduler *playlists.Scheduler PlaylistScheduler *playlists.Scheduler
} }
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverArtBackfillCap int, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server { func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverArtBackfillCap: coverArtBackfillCap, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler} return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler}
} }
func (s *Server) Router() http.Handler { func (s *Server) Router() http.Handler {
@@ -139,7 +138,7 @@ func (s *Server) Router() http.Handler {
if bus == nil { if bus == nil {
bus = eventbus.New() bus = eventbus.New()
} }
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler) api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler)
// /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
+2 -2
View File
@@ -25,9 +25,9 @@ describe('admin covers API', () => {
}); });
it('refetchMissingCovers POSTs to the bulk endpoint', async () => { it('refetchMissingCovers POSTs to the bulk endpoint', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ queued: 7 }); (api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ started: true });
const got = await refetchMissingCovers(); const got = await refetchMissingCovers();
expect(api.post).toHaveBeenCalledWith('/api/admin/covers/refetch-missing', {}); expect(api.post).toHaveBeenCalledWith('/api/admin/covers/refetch-missing', {});
expect(got.queued).toBe(7); expect(got.started).toBe(true);
}); });
}); });
+1 -1
View File
@@ -187,7 +187,7 @@ export async function refetchAlbumCover(albumId: string): Promise<RefetchAlbumCo
} }
export type RefetchMissingResponse = { export type RefetchMissingResponse = {
queued: number; started: boolean;
}; };
export async function refetchMissingCovers(): Promise<RefetchMissingResponse> { export async function refetchMissingCovers(): Promise<RefetchMissingResponse> {
+4 -2
View File
@@ -318,8 +318,10 @@
bulkBusy = true; bulkBusy = true;
bulkResult = null; bulkResult = null;
try { try {
const { queued } = await refetchMissingCovers(); const { started } = await refetchMissingCovers();
bulkResult = `Queued ${queued} albums for cover refetch.`; bulkResult = started
? 'Refetching all missing covers — local sources are fast, remote providers throttle per their limits.'
: 'Could not start the cover refetch.';
await client.invalidateQueries({ queryKey: qk.coverage() }); await client.invalidateQueries({ queryKey: qk.coverage() });
} catch (e) { } catch (e) {
bulkResult = `Failed: ${errCode(e)}`; bulkResult = `Failed: ${errCode(e)}`;
+1 -1
View File
@@ -41,7 +41,7 @@ vi.mock('$lib/api/admin', async () => {
deleteQuarantineFile: vi.fn().mockResolvedValue({}), deleteQuarantineFile: vi.fn().mockResolvedValue({}),
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}), deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
triggerScan: vi.fn().mockResolvedValue({}), triggerScan: vi.fn().mockResolvedValue({}),
refetchMissingCovers: vi.fn().mockResolvedValue({ queued: 0 }), refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }), researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
createScanScheduleQuery: vi.fn(() => createScanScheduleQuery: vi.fn(() =>
readable({ readable({