295d9da361
Parallels EnrichAlbum but with no sidecar layer (artists have no per-artist directory in a music library). Iterates EnabledArtistProviders, writes thumb.jpg + fanart.jpg to <data_dir>/artist-art/<artist_id>/, stamps artist_art_source + artist_art_sources_version atomically. Atomicity: provider returns whichever images it has — partial success (thumb-only or fanart-only) is persisted with whichever paths landed, source still stamped. ErrNotFound from every enabled provider settles the row to 'none' with the current version stamp. Any ErrTransient leaves the row NULL for next-pass retry. CleanupArtistArt removes <data_dir>/artist-art/<artist_id>/ on artist delete; idempotent on missing dir. Hooked into the artist- delete cascade in tracks/service.go after the transaction commits; non-fatal on failure (logs but doesn't fail the delete). EnrichArtistBatch drains rows from ListArtistsMissingArt; the orchestrator's 4th stage (added in T10) calls this. Tests cover the eligibility table, atomicity (thumb-only / fanart-only), all-404-settles, ErrTransient-leaves-NULL, filesystem layout, and CleanupArtistArt idempotency. tracks.Service gains a dataDir field; tracks.NewService takes it as a new parameter. All three call sites updated (server/server.go, api/auth_test.go, api/admin_tracks_test.go). The dataDir flows from cfg.Storage.DataDir → server.New → s.DataDir → tracks.NewService without any change to cmd/minstrel/main.go.
189 lines
6.0 KiB
Go
189 lines
6.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)
|
|
}
|
|
}
|
|
|
|
// 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) (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++
|
|
continue
|
|
}
|
|
// Re-read to classify.
|
|
row, rerr := q.GetArtistByID(ctx, id)
|
|
if rerr != nil {
|
|
failed++
|
|
continue
|
|
}
|
|
if row.ArtistArtSource != nil &&
|
|
*row.ArtistArtSource != "" &&
|
|
*row.ArtistArtSource != "none" {
|
|
succeeded++
|
|
} else {
|
|
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
|
|
}
|