e1d544f59f
Adds an embedded-art layer to the album cover chain between the sidecar lookup and the external provider chain. Reads ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, and MP4 covr atoms via the dhowden/tag library (already a dependency for MBID extraction). Whatever Picard or Lidarr embedded into the file at tag time becomes available without any network call. Diagnostic context: file managers (GNOME Files, Dolphin, etc.) generate audio-file thumbnails by reading exactly this picture block. An album that "has no art" in Minstrel but renders a thumbnail in the file manager is the smoking-gun signal that embedded art is present and we were ignoring it. Adds 'embedded' to the chain so those albums get covered without any external provider hit. Chain after this change: sidecar → embedded → MBCAA → TheAudioDB → Deezer → Last.fm → none Embedded beats every external provider for accuracy because it's the canonical art the operator/Picard chose at tag time, not a guess from a remote catalog. Refactors the post-fetch write logic into Enricher.writeAlbumArt so the embedded layer and the provider chain share the same sidecar-then-managed-cache fallback semantics. Behavior identical to before for the provider path.
361 lines
12 KiB
Go
361 lines
12 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)
|
|
}
|
|
}
|
|
|
|
// Embedded layer: read the picture stored inside the audio file's
|
|
// tag block (ID3v2 APIC, FLAC METADATA_BLOCK_PICTURE, MP4 covr).
|
|
// Picard/Lidarr-tagged libraries typically carry the canonical art
|
|
// here, so this beats every external provider for accuracy and
|
|
// avoids the network round-trip.
|
|
if row.TrackFilePath != nil && *row.TrackFilePath != "" {
|
|
body, eerr := ExtractEmbeddedAlbumArt(*row.TrackFilePath)
|
|
if eerr == nil {
|
|
dest, werr := e.writeAlbumArt(albumID, *row.TrackFilePath, body)
|
|
if werr != nil {
|
|
e.logger.Error("coverart: embedded write failed; leaving NULL",
|
|
"album_id", uuidString(albumID), "err", werr)
|
|
return nil
|
|
}
|
|
return e.recordAlbumCover(ctx, albumID, dest, "embedded", currentVersion)
|
|
}
|
|
if !errors.Is(eerr, ErrNoEmbeddedArt) {
|
|
e.logger.Debug("coverart: embedded extract failed; falling through",
|
|
"album_id", uuidString(albumID), "err", eerr)
|
|
}
|
|
// No embedded art (or extract failed): continue to provider chain.
|
|
}
|
|
|
|
// Build the ref. Every provider receives it and decides whether it
|
|
// can act on the fields available (MBID-only providers return
|
|
// ErrNotFound when MBID is empty; name-based providers fall back to
|
|
// ArtistName + AlbumTitle).
|
|
ref := AlbumRef{
|
|
ArtistName: row.ArtistName,
|
|
AlbumTitle: row.Title,
|
|
}
|
|
if row.Mbid != nil {
|
|
ref.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, ref)
|
|
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)
|
|
}
|
|
dest, werr := e.writeAlbumArt(albumID, *row.TrackFilePath, body)
|
|
if werr != nil {
|
|
e.logger.Error("coverart: write file failed; leaving NULL",
|
|
"album_id", uuidString(albumID),
|
|
"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
|
|
}
|
|
|
|
// writeAlbumArt persists art bytes for an album, preferring the sidecar
|
|
// location next to the track file. Falls back to the managed cache
|
|
// (<DataDir>/album-art/<id>/cover.jpg) when the sidecar location
|
|
// isn't writable (e.g. read-only music mount in a container) and
|
|
// DataDir is set. Returns the path that was actually written.
|
|
//
|
|
// Used by both the embedded-art layer and the external provider
|
|
// chain — both produce the same dest-and-fallback semantics.
|
|
func (e *Enricher) writeAlbumArt(albumID pgtype.UUID, trackFilePath string, body []byte) (string, error) {
|
|
albumDir := filepath.Dir(trackFilePath)
|
|
dest := filepath.Join(albumDir, "cover.jpg")
|
|
werr := os.WriteFile(dest, body, 0o644)
|
|
if werr != nil && e.DataDir != "" {
|
|
fallbackDir := filepath.Join(e.DataDir, "album-art", uuidString(albumID))
|
|
if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil {
|
|
return "", fmt.Errorf("managed-cache mkdir failed (sidecar: %v): %w", werr, mkErr)
|
|
}
|
|
dest = filepath.Join(fallbackDir, "cover.jpg")
|
|
werr = os.WriteFile(dest, body, 0o644)
|
|
}
|
|
if werr != nil {
|
|
return "", fmt.Errorf("write cover: %w", werr)
|
|
}
|
|
return dest, nil
|
|
}
|
|
|
|
// 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])
|
|
}
|