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 /artist-art//). // // 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 }