005965d6de
Operator feedback (2026-05-09): the 500-album cap meant cover art rolled
in over many nightly runs even when local sources (sidecar/embedded)
could finish in minutes. Remove the global cap; rely on the existing
per-provider HTTP throttle (coverart httpClient MinInterval) so local
art is disk-speed and remote providers stay TOS-friendly.
- enricher.go / artist_enricher.go: EnrichBatch, EnrichArtistBatch,
EnrichRetryMissing now treat limit<0 as unbounded (0 still = stage
disabled). The cap was a SQL LIMIT; unbounded uses max int32.
- main.go: RunScanConfig EnrichCap/ArtistEnrichCap = -1 (unbounded).
- Drop LibraryConfig.CoverArtBackfillCap + the
MINSTREL_LIBRARY_COVERART_BACKFILL_CAP env var.
- Drop the now-dead coverBackfillCap param threaded through
server.New + api.Mount + the handlers struct.
- Admin bulk refetch (/api/admin/covers/refetch-missing) now drains
unbounded; response {queued:int} → {started:bool} (the count is
unknowable synchronously for a fire-and-forget drain). Web copy +
client type + Go/web tests updated to match.
No doc refs existed (config.example.yaml / docker-compose / README
never documented the env var).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
241 lines
8.0 KiB
Go
241 lines
8.0 KiB
Go
package coverart
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// EnrichArtist runs the chain for a single artist. Parallel to
|
|
// EnrichAlbum, but no sidecar layer (artists have no per-artist
|
|
// directory in a music library — images live in a managed cache
|
|
// under <dataDir>/artist-art/<artist_id>/).
|
|
//
|
|
// Eligibility:
|
|
// - artist_art_source IS NULL → eligible
|
|
// - artist_art_source IN (theaudiodb) → SKIP (found)
|
|
// - artist_art_source = 'none' AND version = current → SKIP
|
|
// - artist_art_source = 'none' AND version != current → flip to NULL, then enrich
|
|
//
|
|
// Chain: enabled ArtistArtProviders in registration order. First
|
|
// success returns. All-ErrNotFound settles to 'none'. Any
|
|
// ErrTransient leaves the row NULL for next-pass retry.
|
|
func (e *Enricher) EnrichArtist(ctx context.Context, artistID pgtype.UUID, dataDir string) error {
|
|
q := dbq.New(e.pool)
|
|
row, err := q.GetArtistByID(ctx, artistID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("get artist: %w", err)
|
|
}
|
|
|
|
currentVersion := e.settings.CurrentVersion()
|
|
|
|
// Eligibility check.
|
|
currentSource := ""
|
|
if row.ArtistArtSource != nil {
|
|
currentSource = *row.ArtistArtSource
|
|
}
|
|
switch currentSource {
|
|
case "theaudiodb":
|
|
return nil
|
|
case "none":
|
|
if row.ArtistArtSourcesVersion == currentVersion {
|
|
return nil
|
|
}
|
|
if err := q.ClearArtistArtNone(ctx, artistID); err != nil {
|
|
return fmt.Errorf("clear stale artist none: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// Name).
|
|
ref := ArtistRef{Name: row.Name}
|
|
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.EnabledArtistProviders() {
|
|
thumb, fanart, perr := provider.FetchArtistArt(ctx, ref)
|
|
if perr == nil {
|
|
// Atomic write — at least one of (thumb, fanart) is non-nil
|
|
// per the ArtistArtProvider contract; persist whichever
|
|
// landed.
|
|
artistDir := filepath.Join(dataDir, "artist-art", uuidString(artistID))
|
|
if mkErr := os.MkdirAll(artistDir, 0o755); mkErr != nil {
|
|
return fmt.Errorf("mkdir artist-art: %w", mkErr)
|
|
}
|
|
var thumbPath, fanartPath string
|
|
if thumb != nil {
|
|
thumbPath = filepath.Join(artistDir, "thumb.jpg")
|
|
if werr := os.WriteFile(thumbPath, thumb, 0o644); werr != nil {
|
|
e.logger.Error("coverart: write artist thumb failed",
|
|
"artist_id", uuidString(artistID), "err", werr)
|
|
thumbPath = ""
|
|
}
|
|
}
|
|
if fanart != nil {
|
|
fanartPath = filepath.Join(artistDir, "fanart.jpg")
|
|
if werr := os.WriteFile(fanartPath, fanart, 0o644); werr != nil {
|
|
e.logger.Error("coverart: write artist fanart failed",
|
|
"artist_id", uuidString(artistID), "err", werr)
|
|
fanartPath = ""
|
|
}
|
|
}
|
|
return e.recordArtistArt(ctx, artistID, thumbPath, fanartPath, provider.ID(), currentVersion)
|
|
}
|
|
if !errors.Is(perr, ErrNotFound) {
|
|
allWere404 = false
|
|
e.logger.Warn("coverart: artist provider fetch failed; trying next",
|
|
"artist_id", uuidString(artistID),
|
|
"provider", provider.ID(), "err", perr)
|
|
}
|
|
// ErrNotFound: continue to next provider; allWere404 stays true.
|
|
}
|
|
|
|
if allWere404 {
|
|
return e.recordArtistArt(ctx, artistID, "", "", "none", currentVersion)
|
|
}
|
|
return nil // at least one transient failure; leave NULL for retry
|
|
}
|
|
|
|
// recordArtistArt writes the new artist_thumb_path /
|
|
// artist_fanart_path / artist_art_source / artist_art_sources_version
|
|
// atomically.
|
|
func (e *Enricher) recordArtistArt(ctx context.Context, artistID pgtype.UUID, thumbPath, fanartPath, source string, version int32) error {
|
|
q := dbq.New(e.pool)
|
|
src := source
|
|
var thumbArg, fanartArg *string
|
|
if thumbPath != "" {
|
|
thumbArg = &thumbPath
|
|
}
|
|
if fanartPath != "" {
|
|
fanartArg = &fanartPath
|
|
}
|
|
return q.SetArtistArtWithVersion(ctx, dbq.SetArtistArtWithVersionParams{
|
|
ID: artistID,
|
|
ArtistThumbPath: thumbArg,
|
|
ArtistFanartPath: fanartArg,
|
|
ArtistArtSource: &src,
|
|
ArtistArtSourcesVersion: version,
|
|
})
|
|
}
|
|
|
|
// EnrichArtistBatch drains up to limit artists 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 / no-MBID / 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.
|
|
// limit: 0 = stage disabled; >0 = bounded; <0 = unbounded (#388 —
|
|
// no global cap; remote providers self-throttle per provider).
|
|
func (e *Enricher) EnrichArtistBatch(ctx context.Context, limit int, dataDir string,
|
|
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
|
|
if limit == 0 {
|
|
return 0, 0, 0, nil
|
|
}
|
|
queryLimit := int32(limit)
|
|
if limit < 0 {
|
|
queryLimit = 1<<31 - 1 // unbounded
|
|
}
|
|
q := dbq.New(e.pool)
|
|
rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{
|
|
ArtistArtSourcesVersion: e.settings.CurrentVersion(),
|
|
Limit: queryLimit,
|
|
})
|
|
if qerr != nil {
|
|
return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr)
|
|
}
|
|
var settledNone, noMBID, leftNull, errored int
|
|
for _, id := range rows {
|
|
if ctx.Err() != nil {
|
|
e.logArtistBatchSummary(len(rows), processed, succeeded, settledNone, noMBID, leftNull, errored)
|
|
return processed, succeeded, failed, ctx.Err()
|
|
}
|
|
processed++
|
|
if eerr := e.EnrichArtist(ctx, id, dataDir); eerr != nil {
|
|
e.logger.Warn("coverart: artist batch entry failed",
|
|
"artist_id", uuidString(id), "err", eerr)
|
|
failed++
|
|
errored++
|
|
if progressCb != nil {
|
|
progressCb(processed, succeeded, failed)
|
|
}
|
|
continue
|
|
}
|
|
// Re-read to classify.
|
|
row, rerr := q.GetArtistByID(ctx, id)
|
|
if rerr != nil {
|
|
failed++
|
|
errored++
|
|
if progressCb != nil {
|
|
progressCb(processed, succeeded, failed)
|
|
}
|
|
continue
|
|
}
|
|
switch {
|
|
case row.ArtistArtSource != nil &&
|
|
*row.ArtistArtSource != "" &&
|
|
*row.ArtistArtSource != "none":
|
|
succeeded++
|
|
case row.ArtistArtSource != nil && *row.ArtistArtSource == "none":
|
|
settledNone++
|
|
failed++
|
|
case row.Mbid == nil || *row.Mbid == "":
|
|
noMBID++
|
|
failed++
|
|
default:
|
|
leftNull++
|
|
failed++
|
|
}
|
|
if progressCb != nil {
|
|
progressCb(processed, succeeded, failed)
|
|
}
|
|
}
|
|
e.logArtistBatchSummary(len(rows), processed, succeeded, settledNone, noMBID, leftNull, errored)
|
|
return processed, succeeded, failed, nil
|
|
}
|
|
|
|
// logArtistBatchSummary emits a single Info line at end-of-batch with
|
|
// the full category breakdown including MBID-skipped (artists eligible
|
|
// by the SQL filter but skipped inside EnrichArtist due to NULL MBID).
|
|
func (e *Enricher) logArtistBatchSummary(eligible, processed, succeeded, settledNone, noMBID, leftNull, errored int) {
|
|
e.logger.Info("coverart: artist enrichment batch complete",
|
|
"eligible", eligible,
|
|
"processed", processed,
|
|
"succeeded", succeeded,
|
|
"settled_none", settledNone,
|
|
"no_mbid", noMBID,
|
|
"left_null", leftNull,
|
|
"errored", errored)
|
|
}
|
|
|
|
// CleanupArtistArt removes the artist-art directory for a deleted
|
|
// artist. Idempotent — no-op if the directory doesn't exist. Called
|
|
// from the artist-delete cascade in tracks/service.go after the
|
|
// transaction commits.
|
|
func CleanupArtistArt(dataDir string, artistID pgtype.UUID) error {
|
|
dir := filepath.Join(dataDir, "artist-art", uuidString(artistID))
|
|
if err := os.RemoveAll(dir); err != nil {
|
|
return fmt.Errorf("remove artist-art dir: %w", err)
|
|
}
|
|
return nil
|
|
}
|