Files
minstrel/internal/coverart/enricher.go
T
bvandeusen 385eb3b163 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>
2026-05-06 12:52:57 -04:00

259 lines
8.3 KiB
Go

package coverart
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// 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
settings *SettingsService
}
// 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.
//
// 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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil // album was deleted between queueing and processing
}
return fmt.Errorf("get album: %w", err)
}
currentVersion := e.settings.CurrentVersion()
// Eligibility check.
currentSource := ""
if row.CoverArtSource != nil {
currentSource = *row.CoverArtSource
}
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)
}
}
// 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.recordAlbumCover(ctx, albumID, path, "sidecar", currentVersion)
}
}
// MBID guard — providers can't fetch without one.
if row.Mbid == nil || *row.Mbid == "" {
return nil // leave NULL; mbid backfill or future scan can heal
}
mbid := *row.Mbid
// 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)
}
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.
}
if allWere404 {
return e.recordAlbumCover(ctx, albumID, "", "none", currentVersion)
}
return nil // at least one transient failure; leave NULL for retry
}
// 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, dbq.ListAlbumsMissingCoverParams{
CoverArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit),
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing: %w", qerr)
}
for _, id := range rows {
if ctx.Err() != nil {
return processed, succeeded, failed, ctx.Err()
}
processed++
if eerr := e.EnrichAlbum(ctx, id); eerr != nil {
e.logger.Warn("coverart: batch entry failed",
"album_id", uuidString(id), "err", eerr)
failed++
continue
}
// 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 == "theaudiodb") {
succeeded++
} else {
failed++
}
}
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.
func (e *Enricher) EnrichRetryMissing(ctx context.Context, limit int) (int, error) {
if limit <= 0 {
return 0, nil
}
q := dbq.New(e.pool)
rows, err := q.ListAlbumsRetryMissing(ctx, int32(limit))
if err != nil {
return 0, fmt.Errorf("list retry missing: %w", err)
}
for _, r := range rows {
if err := q.ClearAlbumCover(ctx, r.ID); err != nil {
e.logger.Warn("coverart: clear before retry failed",
"album_id", uuidString(r.ID), "err", err)
}
}
processed := 0
for _, r := range rows {
if ctx.Err() != nil {
return processed, ctx.Err()
}
if err := e.EnrichAlbum(ctx, r.ID); err != nil {
e.logger.Warn("coverart: retry-missing entry failed",
"album_id", uuidString(r.ID), "err", err)
}
processed++
}
return processed, nil
}
// 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)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get album: %w", err)
}
// 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 file failed",
"album_id", uuidString(albumID),
"path", *row.CoverArtPath, "err", err)
}
}
if err := q.ClearAlbumCover(ctx, albumID); err != nil {
return fmt.Errorf("clear cover: %w", err)
}
return e.EnrichAlbum(ctx, albumID)
}
// uuidString carries over from the previous enricher.
func uuidString(u pgtype.UUID) string {
if !u.Valid {
return "<nil>"
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}