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:
+123
-87
@@ -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>"
|
||||
|
||||
Reference in New Issue
Block a user