201 lines
6.2 KiB
Go
201 lines
6.2 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 → 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()
|
|
}
|
|
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. 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 "<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])
|
|
}
|