feat(server/m7-cover-sources): artist art enricher (thumb + fanart)

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.
This commit is contained in:
2026-05-06 12:59:53 -04:00
parent 385eb3b163
commit 295d9da361
6 changed files with 739 additions and 8 deletions
+19 -5
View File
@@ -25,6 +25,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -48,18 +49,21 @@ type LidarrUnmonitorer interface {
// the right fallback when Lidarr isn't configured: file + DB delete
// still happen.
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
lidarr LidarrUnmonitorer
pool *pgxpool.Pool
logger *slog.Logger
lidarr LidarrUnmonitorer
dataDir string
}
// NewService constructs a Service. logger may be nil (defaults to
// slog.Default). lidarr may be nil to disable the unmonitor branch.
func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitorer) *Service {
// dataDir is the on-disk root for cached artifacts; used to clean up
// artist-art on artist delete. Empty string disables the cleanup.
func NewService(pool *pgxpool.Pool, logger *slog.Logger, lidarr LidarrUnmonitorer, dataDir string) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, logger: logger, lidarr: lidarr}
return &Service{pool: pool, logger: logger, lidarr: lidarr, dataDir: dataDir}
}
// RemoveTrack deletes the file from disk and the DB rows, runs the
@@ -159,6 +163,16 @@ func (s *Service) RemoveTrack(
return nil, nil, false, fmt.Errorf("commit: %w", err)
}
// Cleanup artist-art filesystem cache if the delete cascade orphaned
// an artist. Non-fatal — the destructive part is done; we just want
// to keep the dataDir tidy.
if deletedArtistID != nil && s.dataDir != "" {
if err := coverart.CleanupArtistArt(s.dataDir, *deletedArtistID); err != nil {
s.logger.Warn("track delete: artist-art cleanup failed",
"artist_id", *deletedArtistID, "err", err)
}
}
// Lidarr unmonitor — non-fatal. The destructive part is done; any
// failure here is informational so the operator can retry manually.
if unmonitor && track.Mbid != nil && *track.Mbid != "" && s.lidarr != nil {