Files
minstrel/internal/coverart/enricher.go
T
bvandeusen 3e9e46f190 feat(server/m7-scan-progress): wire stagePublisher into 4 scan stages
Each of the 4 stage workers (library walk, MBID backfill, cover
enrich, artist art enrich) gains a progressCb parameter that
receives a snapshot of the current running state per item processed.
The orchestrator stores the snapshot in a local var that the
publisher's marshal closure reads at flush time.

scanrun.go's 4 stage blocks are rewritten to construct one publisher
per stage, pass a Tick-firing closure to the worker as progressCb,
and call Flush() after the worker returns (routing any persist error
to captureErr — preserves today's diagnostic behavior).

The previous post-stage UpdateScanRun* calls are removed; the
publisher's Flush handles the final write. Cadence: every 500 items
OR every 1s, whichever fires first.

Outside-orchestrator callers (scanner_test.go, enricher_test.go)
pass nil for progressCb — no-op behavior preserved for tests that
don't need progress observability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:14:50 -04:00

269 lines
8.6 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,
progressCb func(processed, succeeded, failed 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++
if progressCb != nil {
progressCb(processed, succeeded, failed)
}
continue
}
// Re-read to classify.
row, rerr := q.GetAlbumWithFirstTrackPath(ctx, id)
if rerr != nil {
failed++
if progressCb != nil {
progressCb(processed, succeeded, failed)
}
continue
}
if row.CoverArtSource != nil &&
(*row.CoverArtSource == "sidecar" ||
*row.CoverArtSource == "mbcaa" ||
*row.CoverArtSource == "embedded" ||
*row.CoverArtSource == "theaudiodb") {
succeeded++
} else {
failed++
}
if progressCb != nil {
progressCb(processed, succeeded, 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])
}