385eb3b163
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>
259 lines
8.3 KiB
Go
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])
|
|
}
|