126d4e53aa
The existing processed/succeeded/failed tally written to scan_runs
collapses too much to debug "0 successes" symptoms — failed includes
"settled to none (correct)" alongside "left NULL (transient/IO error,
will retry)" alongside "skipped (no MBID)". An operator looking at
{"failed": 144, "succeeded": 0} can't tell whether the enricher is
broken, the upstream is broken, the data is missing MBIDs, or the
artists genuinely aren't in TheAudioDB.
Adds a single end-of-batch Info log per stage with the breakdown:
- eligible: rows returned by the SQL filter
- processed: rows the loop actually touched
- succeeded: art attached (provider success)
- settled_none: tried, no provider had art (legit miss)
- no_mbid: skipped inside the enricher because MBID was NULL
(artist stage only)
- left_null: tried, transient/IO failure, will retry next pass
- errored: internal error during the entry (logged separately)
The JSON written to scan_runs keeps its existing shape so the admin
UI's stage badges aren't disturbed.
322 lines
11 KiB
Go
322 lines
11 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.
|
|
//
|
|
// DataDir is the optional managed-cache root. When set, sidecar writes
|
|
// that fail (e.g. read-only music mount) fall back to
|
|
// <DataDir>/album-art/<album_id>/cover.jpg so enrichment isn't gated
|
|
// on the music directory being writable. Empty disables fallback;
|
|
// tests use empty since they operate on writable tempdirs.
|
|
type Enricher struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
settings *SettingsService
|
|
DataDir string
|
|
}
|
|
|
|
// 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")
|
|
werr := os.WriteFile(dest, body, 0o644)
|
|
if werr != nil && e.DataDir != "" {
|
|
// Sidecar location isn't writable (e.g. RO music mount).
|
|
// Fall back to the managed cache so enrichment isn't
|
|
// gated on the library being writable.
|
|
fallbackDir := filepath.Join(e.DataDir, "album-art", uuidString(albumID))
|
|
if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil {
|
|
e.logger.Error("coverart: managed-cache mkdir failed; leaving NULL",
|
|
"album_id", uuidString(albumID), "fallback_dir", fallbackDir,
|
|
"sidecar_err", werr, "mkdir_err", mkErr)
|
|
return nil
|
|
}
|
|
dest = filepath.Join(fallbackDir, "cover.jpg")
|
|
werr = os.WriteFile(dest, body, 0o644)
|
|
}
|
|
if 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.
|
|
//
|
|
// Emits a single Info log line at end-of-batch with a category
|
|
// breakdown (succeeded / settled-none / left-NULL / errored). The
|
|
// JSON tally written to scan_runs keeps the existing
|
|
// processed/succeeded/failed shape for backwards compat with the
|
|
// admin overview UI; the log line is the operator's diagnostic.
|
|
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)
|
|
}
|
|
var settledNone, leftNull, errored int
|
|
for _, id := range rows {
|
|
if ctx.Err() != nil {
|
|
e.logBatchSummary("album", len(rows), processed, succeeded, settledNone, leftNull, errored)
|
|
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++
|
|
errored++
|
|
if progressCb != nil {
|
|
progressCb(processed, succeeded, failed)
|
|
}
|
|
continue
|
|
}
|
|
// Re-read to classify.
|
|
row, rerr := q.GetAlbumWithFirstTrackPath(ctx, id)
|
|
if rerr != nil {
|
|
failed++
|
|
errored++
|
|
if progressCb != nil {
|
|
progressCb(processed, succeeded, failed)
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case row.CoverArtSource != nil &&
|
|
(*row.CoverArtSource == "sidecar" ||
|
|
*row.CoverArtSource == "mbcaa" ||
|
|
*row.CoverArtSource == "embedded" ||
|
|
*row.CoverArtSource == "theaudiodb"):
|
|
succeeded++
|
|
case row.CoverArtSource != nil && *row.CoverArtSource == "none":
|
|
settledNone++
|
|
failed++
|
|
default:
|
|
leftNull++
|
|
failed++
|
|
}
|
|
if progressCb != nil {
|
|
progressCb(processed, succeeded, failed)
|
|
}
|
|
}
|
|
e.logBatchSummary("album", len(rows), processed, succeeded, settledNone, leftNull, errored)
|
|
return processed, succeeded, failed, nil
|
|
}
|
|
|
|
// logBatchSummary emits a single Info line at end-of-batch with the
|
|
// full category breakdown. Operator-side diagnostic; the
|
|
// processed/succeeded/failed tally collapses too much to debug
|
|
// "0 successes" symptoms by itself.
|
|
func (e *Enricher) logBatchSummary(stage string, eligible, processed, succeeded, settledNone, leftNull, errored int) {
|
|
e.logger.Info("coverart: enrichment batch complete",
|
|
"stage", stage,
|
|
"eligible", eligible,
|
|
"processed", processed,
|
|
"succeeded", succeeded,
|
|
"settled_none", settledNone,
|
|
"left_null", leftNull,
|
|
"errored", errored)
|
|
}
|
|
|
|
// 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])
|
|
}
|