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 → 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. type Enricher struct { pool *pgxpool.Pool logger *slog.Logger fetcher *Fetcher mbcaaOn bool } // 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} } // 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. 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) } // Skip when already settled. NULL is the eligible state. current := "" if row.CoverArtSource != nil { current = *row.CoverArtSource } switch current { case "sidecar", "embedded", "mbcaa", "none": return nil } // Step 1: sidecar lookup. if row.TrackFilePath != nil && *row.TrackFilePath != "" { albumDir := filepath.Dir(*row.TrackFilePath) if path := FindSidecar(albumDir); path != "" { return e.recordCover(ctx, albumID, path, "sidecar") } } // Step 2: MBCAA fallback. Requires fetcher + toggle on + MBID. if !e.mbcaaOn || e.fetcher == nil { return e.recordCover(ctx, albumID, "", "none") } if row.Mbid == nil || *row.Mbid == "" { return e.recordCover(ctx, albumID, "", "none") } body, err := e.fetcher.Fetch(ctx, *row.Mbid) if err != nil { if errors.Is(err, ErrNotFound) { return e.recordCover(ctx, albumID, "", "none") } // 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 } // 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") } 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") } // EnrichBatch drains up to limit albums whose cover_art_source is NULL. // Iterates serially — the fetcher's rate limiter prevents bursts. func (e *Enricher) EnrichBatch(ctx context.Context, limit int) (int, error) { if limit <= 0 { return 0, nil } q := dbq.New(e.pool) rows, err := q.ListAlbumsMissingCover(ctx, int32(limit)) if err != nil { return 0, fmt.Errorf("list missing: %w", 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: batch entry failed", "album_id", uuidString(r.ID), "err", err) } processed++ } return processed, 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() } _ = e.EnrichAlbum(ctx, r.ID) processed++ } 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. 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 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" && 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) } } if err := q.ClearAlbumCover(ctx, albumID); err != nil { return fmt.Errorf("clear cover: %w", err) } 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, }) } func uuidString(u pgtype.UUID) string { if !u.Valid { return "" } 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]) }