feat(server/m7-cover-sources): album enricher uses provider chain

Rewires EnrichAlbum to iterate EnabledAlbumProviders from the
SettingsService instead of a single hardcoded MBCAA fetcher. Sidecar
layer is unchanged (always tried first). Per-row eligibility check
honors cover_art_sources_version: terminal 'found' values skip;
'none' flips to NULL via ClearAlbumCoverNone and proceeds (the DB-
level ListAlbumsMissingCover query guards the version check for batch
callers; RetryAlbum clears via ClearAlbumCover first); NULL is eligible.

The provider chain uses an inline allWere404 accumulator to settle
the row to 'none' only when every provider returned ErrNotFound.
Any provider returning ErrTransient leaves the row NULL for next-
pass retry — even if other providers returned ErrNotFound — since
the transient call's MBID might succeed on a future attempt.

Boot wiring (cmd/minstrel/main.go) replaces the
NewFetcher+NewEnricher(fetcher, mbcaaOn) shape with
NewMBCAAProviderFromConfig (swaps in production User-Agent on the
registered provider) + NewSettingsService + NewEnricher(settings).
The old cfg.Library.CoverArtFromMBCAA flag is no-op'd; DB-backed
settings replace it. Field stays in the config struct for backward
compat with deployed config files.

Consolidates fetcher.go's HTTP logic into provider_mbcaa.go (no more
wrapper indirection): mbcaaProvider now holds cfg/mu/lastCall/client
directly. Deletes fetcher.go and fetcher_test.go. ErrNotFound and
ErrTransient move into provider.go alongside the other sentinels.

Updates admin_covers_test.go and provider_mbcaa_test.go to the new
constructor shapes. Adds multi-provider-chain, transient-leaves-null,
and stale-none-flips-and-retries test cases in enricher_test.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 12:52:57 -04:00
parent 392b8aa12a
commit 385eb3b163
9 changed files with 521 additions and 381 deletions
+123 -87
View File
@@ -15,25 +15,36 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Enricher orchestrates the sidecar → MBCAA → record cycle for one or many
// albums. Owns no DB pool itself; receives one per call so the boot
// backfill, post-scan trigger, and admin endpoints share state.
// Enricher orchestrates the sidecar → provider chain → record cycle
// for one or many albums. Owns no DB pool itself; receives one per
// call so the boot backfill, post-scan trigger, and admin endpoints
// share state.
type Enricher struct {
pool *pgxpool.Pool
logger *slog.Logger
fetcher *Fetcher
mbcaaOn bool
pool *pgxpool.Pool
logger *slog.Logger
settings *SettingsService
}
// NewEnricher constructs an Enricher. Pass mbcaaOn=false to disable upstream
// fetches entirely (sidecar-only mode for paranoid operators).
func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, fetcher *Fetcher, mbcaaOn bool) *Enricher {
return &Enricher{pool: pool, logger: logger, fetcher: fetcher, mbcaaOn: mbcaaOn}
// NewEnricher constructs an Enricher.
func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsService) *Enricher {
return &Enricher{pool: pool, logger: logger, settings: settings}
}
// EnrichAlbum runs the chain for a single album. Idempotent: skips when
// cover_art_source is already set to a terminal value (sidecar/embedded/
// mbcaa/none). The admin retry endpoint clears the field before calling.
// EnrichAlbum runs the chain for a single album.
//
// Eligibility:
// - cover_art_source IS NULL → eligible
// - cover_art_source IN (sidecar/embedded/ → SKIP (found; version irrelevant)
// mbcaa/theaudiodb)
// - cover_art_source = 'none' → flip to NULL via ClearAlbumCoverNone,
// then enrich (the DB-level query ListAlbumsMissingCover already
// guards the version check for batch callers; direct callers such
// as RetryAlbum clear via ClearAlbumCover first so we never see
// a current-version 'none' in normal flow)
//
// Chain: sidecar → enabled AlbumCoverProviders in registration order.
// First success returns. All-ErrNotFound settles to 'none'. Any
// ErrTransient leaves the row NULL for next-pass retry.
func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error {
q := dbq.New(e.pool)
row, err := q.GetAlbumWithFirstTrackPath(ctx, albumID)
@@ -44,98 +55,131 @@ func (e *Enricher) EnrichAlbum(ctx context.Context, albumID pgtype.UUID) error {
return fmt.Errorf("get album: %w", err)
}
// Skip when already settled. NULL is the eligible state.
current := ""
currentVersion := e.settings.CurrentVersion()
// Eligibility check.
currentSource := ""
if row.CoverArtSource != nil {
current = *row.CoverArtSource
currentSource = *row.CoverArtSource
}
switch current {
case "sidecar", "embedded", "mbcaa", "none":
return nil
switch currentSource {
case "sidecar", "embedded", "mbcaa", "theaudiodb":
return nil // already found; version irrelevant
case "none":
// Flip to NULL and proceed. ListAlbumsMissingCover already
// filters to stale-version 'none' rows for batch callers;
// RetryAlbum clears to NULL before calling us.
if err := q.ClearAlbumCoverNone(ctx, albumID); err != nil {
return fmt.Errorf("clear stale none: %w", err)
}
}
// Step 1: sidecar lookup.
// Sidecar layer (always tried first; not a Provider).
if row.TrackFilePath != nil && *row.TrackFilePath != "" {
albumDir := filepath.Dir(*row.TrackFilePath)
if path := FindSidecar(albumDir); path != "" {
return e.recordCover(ctx, albumID, path, "sidecar")
return e.recordAlbumCover(ctx, albumID, path, "sidecar", currentVersion)
}
}
// Step 2: MBCAA fallback. Requires fetcher + toggle on + MBID.
// When the toggle is disabled OR the album has no MBID, leave
// cover_art_source NULL so a future scan can retry once the
// scanner heals the MBID. 'none' is reserved for genuine MBCAA
// 404s where we know the album really isn't in the archive.
if !e.mbcaaOn || e.fetcher == nil {
return nil
}
// MBID guard — providers can't fetch without one.
if row.Mbid == nil || *row.Mbid == "" {
return nil
return nil // leave NULL; mbid backfill or future scan can heal
}
mbid := *row.Mbid
body, err := e.fetcher.Fetch(ctx, *row.Mbid)
if err != nil {
if errors.Is(err, ErrNotFound) {
return e.recordCover(ctx, albumID, "", "none")
// Provider chain. allWere404 tracks whether every provider
// returned ErrNotFound — only then do we settle to 'none'. Any
// transient failure leaves the row NULL for next-pass retry.
allWere404 := true
for _, provider := range e.settings.EnabledAlbumProviders() {
body, perr := provider.FetchAlbumCover(ctx, mbid)
if perr == nil {
if row.TrackFilePath == nil || *row.TrackFilePath == "" {
// Defensive: shouldn't happen if MBID was set on an
// album with no tracks, but record 'none' rather than
// crash.
return e.recordAlbumCover(ctx, albumID, "", "none", currentVersion)
}
albumDir := filepath.Dir(*row.TrackFilePath)
dest := filepath.Join(albumDir, "cover.jpg")
if werr := os.WriteFile(dest, body, 0o644); werr != nil {
e.logger.Error("coverart: write file failed; leaving NULL",
"album_id", uuidString(albumID), "path", dest,
"provider", provider.ID(), "err", werr)
return nil
}
return e.recordAlbumCover(ctx, albumID, dest, provider.ID(), currentVersion)
}
// ErrTransient or context cancellation: leave NULL for retry.
e.logger.Warn("coverart: fetch failed; leaving for retry",
"album_id", uuidString(albumID), "err", err)
return nil
if !errors.Is(perr, ErrNotFound) {
allWere404 = false
e.logger.Warn("coverart: provider fetch failed; trying next",
"album_id", uuidString(albumID),
"provider", provider.ID(), "err", perr)
}
// ErrNotFound: continue to next provider; allWere404 stays true.
}
// Step 3: write to album dir as cover.jpg, record path + 'mbcaa'.
if row.TrackFilePath == nil || *row.TrackFilePath == "" {
// Defensive: shouldn't happen if MBID was set on an album with
// no tracks, but record 'none' rather than crash.
return e.recordCover(ctx, albumID, "", "none")
if allWere404 {
return e.recordAlbumCover(ctx, albumID, "", "none", currentVersion)
}
albumDir := filepath.Dir(*row.TrackFilePath)
dest := filepath.Join(albumDir, "cover.jpg")
if err := os.WriteFile(dest, body, 0o644); err != nil {
e.logger.Error("coverart: write file failed; leaving NULL",
"album_id", uuidString(albumID), "path", dest, "err", err)
return nil
}
return e.recordCover(ctx, albumID, dest, "mbcaa")
return nil // at least one transient failure; leave NULL for retry
}
// EnrichBatch drains up to limit albums whose cover_art_source is NULL.
// Iterates serially — the fetcher's rate limiter prevents bursts.
// Returns per-batch tallies for the orchestrator's scan-run record.
//
// "succeeded" means the album ended up with a non-NULL cover_art_source
// other than 'none' (i.e. we found art via sidecar or MBCAA). "failed"
// counts both EnrichAlbum errors and rows that ended at 'none'.
// recordAlbumCover writes the new cover_art_path / cover_art_source /
// cover_art_sources_version atomically.
func (e *Enricher) recordAlbumCover(ctx context.Context, albumID pgtype.UUID, path, source string, version int32) error {
q := dbq.New(e.pool)
src := source
var pathArg *string
if path != "" {
pathArg = &path
}
return q.SetAlbumCoverWithVersion(ctx, dbq.SetAlbumCoverWithVersionParams{
ID: albumID,
CoverArtPath: pathArg,
CoverArtSource: &src,
CoverArtSourcesVersion: version,
})
}
// EnrichBatch drains up to limit albums whose eligibility loop
// determines they need processing. Iterates serially. Returns
// per-batch tallies for the orchestrator's scan-run record.
func (e *Enricher) EnrichBatch(ctx context.Context, limit int) (processed, succeeded, failed int, err error) {
if limit <= 0 {
return 0, 0, 0, nil
}
q := dbq.New(e.pool)
rows, qerr := q.ListAlbumsMissingCover(ctx, int32(limit))
rows, qerr := q.ListAlbumsMissingCover(ctx, dbq.ListAlbumsMissingCoverParams{
CoverArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit),
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing: %w", qerr)
}
for _, r := range rows {
for _, id := range rows {
if ctx.Err() != nil {
return processed, succeeded, failed, ctx.Err()
}
processed++
if eerr := e.EnrichAlbum(ctx, r.ID); eerr != nil {
if eerr := e.EnrichAlbum(ctx, id); eerr != nil {
e.logger.Warn("coverart: batch entry failed",
"album_id", uuidString(r.ID), "err", eerr)
"album_id", uuidString(id), "err", eerr)
failed++
continue
}
// Re-read the source to classify the outcome.
row, rerr := q.GetAlbumWithFirstTrackPath(ctx, r.ID)
// Re-read to classify.
row, rerr := q.GetAlbumWithFirstTrackPath(ctx, id)
if rerr != nil {
failed++
continue
}
if row.CoverArtSource != nil &&
(*row.CoverArtSource == "sidecar" || *row.CoverArtSource == "mbcaa" || *row.CoverArtSource == "embedded") {
(*row.CoverArtSource == "sidecar" ||
*row.CoverArtSource == "mbcaa" ||
*row.CoverArtSource == "embedded" ||
*row.CoverArtSource == "theaudiodb") {
succeeded++
} else {
failed++
@@ -144,8 +188,9 @@ func (e *Enricher) EnrichBatch(ctx context.Context, limit int) (processed, succe
return processed, succeeded, failed, nil
}
// EnrichRetryMissing drains both NULL and 'none' rows. Used by the admin
// bulk-retry endpoint. Clears 'none' to NULL first so EnrichAlbum picks them up.
// EnrichRetryMissing drains both NULL and 'none' rows. Used by the
// admin bulk-retry endpoint. Clears 'none' to NULL first so
// EnrichAlbum picks them up.
func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) {
if limit <= 0 {
return 0, nil
@@ -175,9 +220,7 @@ func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, erro
return processed, nil
}
// RetryAlbum is the admin per-album retry path. Clears the album's cover
// state, deletes the file if we wrote it (cover_art_source = 'mbcaa'),
// then re-runs EnrichAlbum synchronously.
// RetryAlbum is the admin per-album retry path.
func (e *Enricher) RetryAlbum(ctx context.Context, albumID pgtype.UUID) error {
q := dbq.New(e.pool)
row, err := q.GetAlbumWithFirstTrackPath(ctx, albumID)
@@ -187,14 +230,16 @@ func (e *Enricher) RetryAlbum(ctx context.Context, albumID pgtype.UUID) error {
}
return fmt.Errorf("get album: %w", err)
}
// Delete file only when we previously wrote it (mbcaa source). Operator
// sidecars stay; embedded extracts (future) live in a managed cache that
// has its own lifecycle.
if row.CoverArtSource != nil && *row.CoverArtSource == "mbcaa" &&
// Delete the file only if we previously wrote it (mbcaa or
// theaudiodb). Operator sidecars stay; embedded extracts (future)
// live in a managed cache with its own lifecycle.
if row.CoverArtSource != nil &&
(*row.CoverArtSource == "mbcaa" || *row.CoverArtSource == "theaudiodb") &&
row.CoverArtPath != nil && *row.CoverArtPath != "" {
if err := os.Remove(*row.CoverArtPath); err != nil && !os.IsNotExist(err) {
e.logger.Warn("coverart: remove old mbcaa file failed",
"album_id", uuidString(albumID), "path", *row.CoverArtPath, "err", err)
e.logger.Warn("coverart: remove old file failed",
"album_id", uuidString(albumID),
"path", *row.CoverArtPath, "err", err)
}
}
if err := q.ClearAlbumCover(ctx, albumID); err != nil {
@@ -203,16 +248,7 @@ func (e *Enricher) RetryAlbum(ctx context.Context, albumID pgtype.UUID) error {
return e.EnrichAlbum(ctx, albumID)
}
func (e *Enricher) recordCover(ctx context.Context, albumID pgtype.UUID, path, source string) error {
q := dbq.New(e.pool)
src := source
return q.SetAlbumCover(ctx, dbq.SetAlbumCoverParams{
ID: albumID,
Column2: path,
CoverArtSource: &src,
})
}
// uuidString carries over from the previous enricher.
func uuidString(u pgtype.UUID) string {
if !u.Valid {
return "<nil>"