Files
minstrel/internal/coverart/artist_enricher.go
T
bvandeusen 3e9e46f190 feat(server/m7-scan-progress): wire stagePublisher into 4 scan stages
Each of the 4 stage workers (library walk, MBID backfill, cover
enrich, artist art enrich) gains a progressCb parameter that
receives a snapshot of the current running state per item processed.
The orchestrator stores the snapshot in a local var that the
publisher's marshal closure reads at flush time.

scanrun.go's 4 stage blocks are rewritten to construct one publisher
per stage, pass a Tick-firing closure to the worker as progressCb,
and call Flush() after the worker returns (routing any persist error
to captureErr — preserves today's diagnostic behavior).

The previous post-stage UpdateScanRun* calls are removed; the
publisher's Flush handles the final write. Cadence: every 500 items
OR every 1s, whichever fires first.

Outside-orchestrator callers (scanner_test.go, enricher_test.go)
pass nil for progressCb — no-op behavior preserved for tests that
don't need progress observability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 21:14:50 -04:00

199 lines
6.2 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)
}
}
// MBID guard.
if row.Mbid == nil || *row.Mbid == "" {
return nil
}
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, mbid)
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.
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
}
q := dbq.New(e.pool)
rows, qerr := q.ListArtistsMissingArt(ctx, dbq.ListArtistsMissingArtParams{
ArtistArtSourcesVersion: e.settings.CurrentVersion(),
Limit: int32(limit),
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list missing artist art: %w", qerr)
}
for _, id := range rows {
if ctx.Err() != nil {
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++
if progressCb != nil {
progressCb(processed, succeeded, failed)
}
continue
}
// Re-read to classify.
row, rerr := q.GetArtistByID(ctx, id)
if rerr != nil {
failed++
if progressCb != nil {
progressCb(processed, succeeded, failed)
}
continue
}
if row.ArtistArtSource != nil &&
*row.ArtistArtSource != "" &&
*row.ArtistArtSource != "none" {
succeeded++
} else {
failed++
}
if progressCb != nil {
progressCb(processed, succeeded, failed)
}
}
return processed, succeeded, failed, nil
}
// 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
}