From 005965d6de0b6f058481ae9d772aa147c4bc1881 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 16 May 2026 18:41:21 -0400 Subject: [PATCH] feat(coverart): #388 remove global cover-art backfill cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/minstrel/main.go | 14 +++--- internal/api/admin_covers.go | 13 ++--- internal/api/admin_covers_test.go | 7 ++- internal/api/api.go | 72 ++++++++++++++-------------- internal/api/library_test.go | 2 +- internal/config/config.go | 17 ++----- internal/coverart/artist_enricher.go | 10 +++- internal/coverart/enricher.go | 22 +++++++-- internal/library/scanrun.go | 8 ++-- internal/server/server.go | 21 ++++---- web/src/lib/api/admin.covers.test.ts | 4 +- web/src/lib/api/admin.ts | 2 +- web/src/routes/admin/+page.svelte | 6 ++- web/src/routes/admin/admin.test.ts | 2 +- 14 files changed, 107 insertions(+), 93 deletions(-) diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 29b9e11c..84a198d7 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -105,8 +105,8 @@ func run() error { logger.With("component", "scan_run"), library.RunScanConfig{ BackfillCap: 5000, - EnrichCap: cfg.Library.CoverArtBackfillCap, - ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, + EnrichCap: -1, // #388: no global cap; per-provider rate limits + ArtistEnrichCap: -1, DataDir: cfg.Storage.DataDir, }, ); err != nil { @@ -122,8 +122,8 @@ func run() error { logger.With("component", "scan_run"), library.RunScanConfig{ BackfillCap: 5000, - EnrichCap: cfg.Library.CoverArtBackfillCap, - ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, + EnrichCap: -1, // #388: no global cap; per-provider rate limits + ArtistEnrichCap: -1, DataDir: cfg.Storage.DataDir, }, ); err != nil { @@ -194,8 +194,8 @@ func run() error { scanCfg := library.RunScanConfig{ BackfillCap: 5000, - EnrichCap: cfg.Library.CoverArtBackfillCap, - ArtistEnrichCap: cfg.Library.CoverArtBackfillCap, + EnrichCap: -1, // #388: no global cap; per-provider rate limits + ArtistEnrichCap: -1, DataDir: cfg.Storage.DataDir, } scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"), @@ -203,7 +203,7 @@ func run() error { scheduler.Start(ctx) srv := server.New(logger, pool, scanner, subsonic.Config{ 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.PlaylistScheduler = playlistScheduler httpServer := &http.Server{ diff --git a/internal/api/admin_covers.go b/internal/api/admin_covers.go index df9c8e18..d354ce31 100644 --- a/internal/api/admin_covers.go +++ b/internal/api/admin_covers.go @@ -48,19 +48,20 @@ func (h *handlers) handleAdminAlbumRefetchCover(w http.ResponseWriter, r *http.R } type adminBulkRefetchResp struct { - Queued int `json:"queued"` + Started bool `json:"started"` } // handleAdminBulkRefetchCovers implements POST /api/admin/covers/refetch-missing. -// Returns immediately; actual drain runs in a background goroutine bounded by -// the configured backfill cap. +// Returns immediately; the drain runs UNBOUNDED in a background goroutine +// (#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) { - cap := h.coverArtBackfillCap go func() { 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) } }() - writeJSON(w, http.StatusOK, adminBulkRefetchResp{Queued: cap}) + writeJSON(w, http.StatusOK, adminBulkRefetchResp{Started: true}) } diff --git a/internal/api/admin_covers_test.go b/internal/api/admin_covers_test.go index 49aa736b..7f574ce7 100644 --- a/internal/api/admin_covers_test.go +++ b/internal/api/admin_covers_test.go @@ -53,7 +53,6 @@ func testHandlersWithCovers(t *testing.T) (*handlers, *coverart.Enricher) { } enricher := coverart.NewEnricher(pool, slog.Default(), settings) h.coverart = enricher - h.coverArtBackfillCap = 100 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") == "" { 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 { t.Fatalf("decode: %v", err) } - if resp.Queued != h.coverArtBackfillCap { - t.Errorf("queued = %d, want %d (cap)", resp.Queued, h.coverArtBackfillCap) + if !resp.Started { + t.Errorf("started = false, want true (bulk refetch should acknowledge)") } } diff --git a/internal/api/api.go b/internal/api/api.go index 4f724493..9935cfc0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -28,26 +28,25 @@ 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, 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())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, - rng: rng.Float64, - lidarrCfg: lidarrCfg, - lidarrRequests: lidarrReqs, - lidarrQuarantine: lidarrQuar, - tracks: tracksSvc, - playlists: playlistsSvc, - coverart: coverEnricher, - coverArtBackfillCap: coverBackfillCap, - coverSettings: coverSettings, - scanner: scanner, - scanCfg: scanCfg, - scheduler: scheduler, - dataDir: dataDir, - mailer: sender, - eventbus: bus, - playlistScheduler: playlistScheduler, + rng: rng.Float64, + lidarrCfg: lidarrCfg, + lidarrRequests: lidarrReqs, + lidarrQuarantine: lidarrQuar, + tracks: tracksSvc, + playlists: playlistsSvc, + coverart: coverEnricher, + coverSettings: coverSettings, + scanner: scanner, + scanCfg: scanCfg, + scheduler: scheduler, + dataDir: dataDir, + mailer: sender, + eventbus: bus, + playlistScheduler: playlistScheduler, } 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 { - pool *pgxpool.Pool - logger *slog.Logger - events *playevents.Writer - recCfg config.RecommendationConfig - rng func() float64 - lidarrCfg *lidarrconfig.Service - lidarrRequests *lidarrrequests.Service - lidarrQuarantine *lidarrquarantine.Service - tracks *tracks.Service - playlists *playlists.Service - coverart *coverart.Enricher - coverArtBackfillCap int - coverSettings *coverart.SettingsService - scanner *library.Scanner - scanCfg library.RunScanConfig - scheduler *library.Scheduler - dataDir string - mailer mailer.Sender - eventbus *eventbus.Bus - playlistScheduler *playlists.Scheduler + pool *pgxpool.Pool + logger *slog.Logger + events *playevents.Writer + recCfg config.RecommendationConfig + rng func() float64 + lidarrCfg *lidarrconfig.Service + lidarrRequests *lidarrrequests.Service + lidarrQuarantine *lidarrquarantine.Service + tracks *tracks.Service + playlists *playlists.Service + coverart *coverart.Enricher + coverSettings *coverart.SettingsService + scanner *library.Scanner + scanCfg library.RunScanConfig + scheduler *library.Scheduler + dataDir string + mailer mailer.Sender + eventbus *eventbus.Bus + playlistScheduler *playlists.Scheduler } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 4cc8a3c5..845aed75 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -444,7 +444,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 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{ "/api/artists", diff --git a/internal/config/config.go b/internal/config/config.go index 4320fc20..986dad6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -56,11 +56,10 @@ type LogConfig struct { } type LibraryConfig struct { - ScanPaths []string `yaml:"scan_paths"` - ScanOnStartup bool `yaml:"scan_on_startup"` - 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 + ScanPaths []string `yaml:"scan_paths"` + ScanOnStartup bool `yaml:"scan_on_startup"` + CoverArtFromMBCAA bool `yaml:"coverart_from_mbcaa"` // default true + ContactEmail string `yaml:"contact_email"` // optional override for MBCAA UA } // SubsonicConfig controls wire-format concessions on the /rest/* surface. @@ -120,8 +119,7 @@ func Default() Config { RadioSizeMax: 200, }, Library: LibraryConfig{ - CoverArtFromMBCAA: true, - CoverArtBackfillCap: 500, + CoverArtFromMBCAA: true, }, } } @@ -177,11 +175,6 @@ func applyEnv(cfg *Config) { 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 { cfg.Library.ContactEmail = v } diff --git a/internal/coverart/artist_enricher.go b/internal/coverart/artist_enricher.go index 9b774991..48187a42 100644 --- a/internal/coverart/artist_enricher.go +++ b/internal/coverart/artist_enricher.go @@ -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 // processed/succeeded/failed shape for backwards compat with the // 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, progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) { - if limit <= 0 { + if limit == 0 { return 0, 0, 0, nil } + queryLimit := int32(limit) + if limit < 0 { + queryLimit = 1<<31 - 1 // unbounded + } q := dbq.New(e.pool) rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{ ArtistArtSourcesVersion: e.settings.CurrentVersion(), - Limit: int32(limit), + Limit: queryLimit, }) if qerr != nil { return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr) diff --git a/internal/coverart/enricher.go b/internal/coverart/enricher.go index f45eafcf..cb7dda3e 100644 --- a/internal/coverart/enricher.go +++ b/internal/coverart/enricher.go @@ -213,15 +213,23 @@ func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, pa // JSON tally written to scan_runs keeps the existing // processed/succeeded/failed shape for backwards compat with the // 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, progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) { - if limit <= 0 { + if limit == 0 { return 0, 0, 0, nil } + queryLimit := int32(limit) + if limit < 0 { + queryLimit = 1<<31 - 1 // unbounded + } q := dbq.New(e.pool) rows, qerr := q.ListAlbumsMissingCover(ctx, dbq.ListAlbumsMissingCoverParams{ CoverArtSourcesVersion: e.settings.CurrentVersion(), - Limit: int32(limit), + Limit: queryLimit, }) if qerr != nil { 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 // admin bulk-retry endpoint. Clears 'none' to NULL first so // 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) { - if limit <= 0 { + if limit == 0 { return 0, nil } + queryLimit := int32(limit) + if limit < 0 { + queryLimit = 1<<31 - 1 // unbounded + } q := dbq.New(e.pool) - rows, err := q.ListAlbumsRetryMissing(ctx, int32(limit)) + rows, err := q.ListAlbumsRetryMissing(ctx, queryLimit) if err != nil { return 0, fmt.Errorf("list retry missing: %w", err) } diff --git a/internal/library/scanrun.go b/internal/library/scanrun.go index d2b72566..a6c9de3d 100644 --- a/internal/library/scanrun.go +++ b/internal/library/scanrun.go @@ -49,9 +49,11 @@ type ArtistArtEnrichStageTallies struct { Failed int `json:"failed"` } -// RunScanConfig assembles the knobs for the orchestrator. BackfillCap and -// EnrichCap mirror the existing boot-time caps (5000 / coverArtBackfillCap) -// so manual triggers don't accidentally drain the whole library. +// RunScanConfig assembles the knobs for the orchestrator. BackfillCap is +// the album/artist-MBID-backfill batch cap (5000); EnrichCap / +// 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 { BackfillCap int EnrichCap int diff --git a/internal/server/server.go b/internal/server/server.go index 8e6f5730..cf381871 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -72,14 +72,13 @@ type Server struct { // playlist cover collages under /playlist_covers/). Empty // strings are tolerated by tests that don't exercise persisted-cover // codepaths; production callers should pass a writable directory. - DataDir string - BrandingCfg config.BrandingConfig - CoverEnricher *coverart.Enricher - CoverArtBackfillCap int - CoverSettings *coverart.SettingsService - LibraryScanner *library.Scanner - ScanCfg library.RunScanConfig - Scheduler *library.Scheduler + DataDir string + BrandingCfg config.BrandingConfig + CoverEnricher *coverart.Enricher + CoverSettings *coverart.SettingsService + LibraryScanner *library.Scanner + ScanCfg library.RunScanConfig + Scheduler *library.Scheduler // Bus is the live-event bus shared with background workers (the // lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go. // When nil, Router() constructs a local fallback (test contexts). @@ -92,8 +91,8 @@ type Server struct { 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 { - 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} +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, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler} } func (s *Server) Router() http.Handler { @@ -139,7 +138,7 @@ func (s *Server) Router() http.Handler { if bus == nil { 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 // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second diff --git a/web/src/lib/api/admin.covers.test.ts b/web/src/lib/api/admin.covers.test.ts index 5bc89734..75b2aa0e 100644 --- a/web/src/lib/api/admin.covers.test.ts +++ b/web/src/lib/api/admin.covers.test.ts @@ -25,9 +25,9 @@ describe('admin covers API', () => { }); it('refetchMissingCovers POSTs to the bulk endpoint', async () => { - (api.post as unknown as ReturnType).mockResolvedValueOnce({ queued: 7 }); + (api.post as unknown as ReturnType).mockResolvedValueOnce({ started: true }); const got = await refetchMissingCovers(); expect(api.post).toHaveBeenCalledWith('/api/admin/covers/refetch-missing', {}); - expect(got.queued).toBe(7); + expect(got.started).toBe(true); }); }); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index ece4d1ed..20ca2da3 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -187,7 +187,7 @@ export async function refetchAlbumCover(albumId: string): Promise { diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index 5f570a37..54718b28 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -318,8 +318,10 @@ bulkBusy = true; bulkResult = null; try { - const { queued } = await refetchMissingCovers(); - bulkResult = `Queued ${queued} albums for cover refetch.`; + const { started } = await refetchMissingCovers(); + 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() }); } catch (e) { bulkResult = `Failed: ${errCode(e)}`; diff --git a/web/src/routes/admin/admin.test.ts b/web/src/routes/admin/admin.test.ts index 55e6bfbc..a0272596 100644 --- a/web/src/routes/admin/admin.test.ts +++ b/web/src/routes/admin/admin.test.ts @@ -41,7 +41,7 @@ vi.mock('$lib/api/admin', async () => { deleteQuarantineFile: vi.fn().mockResolvedValue({}), deleteQuarantineViaLidarr: 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 }), createScanScheduleQuery: vi.fn(() => readable({