Files
minstrel/internal/coverart/provider_mbcaa.go
T
bvandeusenandClaude Opus 5.5 77389f88bc
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s
feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
Minstrel keeps its own record of what artists have released, read from
MusicBrainz, so the wanted list, search and import no longer need Lidarr.

- internal/musicbrainz: searches (artists, release groups, recordings) and
  browses (an artist's release groups; a group's releases with track lists).
  One process-wide pacer at 1.1s, shared with tag enrichment, because
  MusicBrainz's limit is per address. A 503 throttle is retried.
- Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups
  (a collaboration belongs to each credited artist), mb_releases,
  mb_release_tracks.
- internal/catalogue: Discography and Releases serve the held copy and
  re-read it when older than 7 or 30 days. An unreachable MusicBrainz
  serves the stale copy, marked. An hourly pass re-reads due artists.
- Art for what isn't held comes through /api/catalogue/art from the
  enabled cover-art sources (Cover Art Archive by release group, then the
  name-based sources) and is kept on disk, so the browser never fetches a
  third-party host. A miss is a 404 and the card shows its icon.
- Request search moves from /api/lidarr/search to /api/catalogue/search,
  with the same response shape. Albums are release groups, and "in
  library" matches albums.release_group_mbid. A 10-minute cache absorbs
  search-as-you-type. Discover suggestions point at the art endpoint
  instead of looking up Lidarr.
- Android calls the new path and loads Discover art through ServerImage.
  Installed builds older than this one lose Discover search until they
  update (no compatibility path, rule 22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:08:22 -04:00

174 lines
4.9 KiB
Go

package coverart
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
)
// FetcherConfig assembles the operator-tunable knobs for the MBCAA
// HTTP client. Kept exported because main.go's boot wiring constructs
// one to pass into NewMBCAAProviderFromConfig.
type FetcherConfig struct {
BaseURL string // default "https://coverartarchive.org"
UserAgent string // e.g. "Minstrel/<version> (<contact>)"
MinPeriod time.Duration // 1s default per MBCAA etiquette
HTTPClient *http.Client // optional override for tests
}
// Sample MBID used by TestConnection. Beatles' "Abbey Road" UK release.
const mbcaaTestSampleMBID = "f4b3df80-8a6c-3e87-b51e-5c1ee70a78ee"
// mbcaaProvider implements Provider, AlbumCoverProvider, and
// TestableProvider against the MusicBrainz Cover Art Archive.
// Safe for concurrent use; the internal mutex serialises HTTP
// requests so the rate limit is respected.
type mbcaaProvider struct {
cfg FetcherConfig
enabled atomic.Bool
mu sync.Mutex
lastCall time.Time
client *http.Client
}
func init() {
Register(newMBCAAProvider(FetcherConfig{}))
}
// NewMBCAAProviderFromConfig replaces the registered MBCAA provider's
// internal config with the supplied one. main.go calls this at boot
// to set the production User-Agent.
func NewMBCAAProviderFromConfig(cfg FetcherConfig) {
p, err := ProviderByID("mbcaa")
if err != nil {
Register(newMBCAAProvider(cfg))
return
}
mp, ok := p.(*mbcaaProvider)
if !ok {
panic("coverart: provider with ID 'mbcaa' is not *mbcaaProvider")
}
cfg = applyMBCAADefaults(cfg)
mp.mu.Lock()
mp.cfg = cfg
mp.client = cfg.HTTPClient
mp.mu.Unlock()
}
func newMBCAAProvider(cfg FetcherConfig) *mbcaaProvider {
cfg = applyMBCAADefaults(cfg)
return &mbcaaProvider{cfg: cfg, client: cfg.HTTPClient}
}
func applyMBCAADefaults(cfg FetcherConfig) FetcherConfig {
if cfg.BaseURL == "" {
cfg.BaseURL = "https://coverartarchive.org"
}
if cfg.MinPeriod == 0 {
cfg.MinPeriod = time.Second
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
return cfg
}
func (p *mbcaaProvider) ID() string { return "mbcaa" }
func (p *mbcaaProvider) DisplayName() string { return "Cover Art Archive" }
func (p *mbcaaProvider) RequiresAPIKey() bool { return false }
func (p *mbcaaProvider) DefaultEnabled() bool { return true }
func (p *mbcaaProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
return nil
}
// FetchAlbumCover retrieves the 500px front cover for the given
// release MBID. MBCAA is MBID-only; returns ErrNotFound when
// ref.MBID is empty. Returns ErrNotFound on 404 or ErrTransient otherwise.
func (p *mbcaaProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error) {
return p.fetchFront(ctx, "release", ref.MBID)
}
// FetchReleaseGroupCover retrieves the 500px front cover the Cover Art
// Archive chose for a release group: what a search result or a wanted album
// shows before any edition of it is held (M502 #5519).
func (p *mbcaaProvider) FetchReleaseGroupCover(ctx context.Context, groupMBID string) ([]byte, error) {
return p.fetchFront(ctx, "release-group", groupMBID)
}
// fetchFront fetches /{entity}/{mbid}/front-500, honouring the rate limit.
func (p *mbcaaProvider) fetchFront(ctx context.Context, entity, mbid string) ([]byte, error) {
if !p.enabled.Load() {
return nil, ErrNotFound // defensive; enricher already filters on enabled
}
if mbid == "" {
return nil, ErrNotFound // MBCAA is MBID-only
}
// Rate limit guard.
p.mu.Lock()
cfg := p.cfg
if cfg.MinPeriod > 0 {
wait := time.Until(p.lastCall.Add(cfg.MinPeriod))
if wait > 0 {
timer := time.NewTimer(wait)
p.mu.Unlock()
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
p.mu.Lock()
}
}
p.lastCall = time.Now()
p.mu.Unlock()
url := fmt.Sprintf("%s/%s/%s/front-500", cfg.BaseURL, entity, mbid)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("coverart: build request: %w", err)
}
req.Header.Set("User-Agent", cfg.UserAgent)
req.Header.Set("Accept", "image/*")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTransient, err)
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode == http.StatusNotFound:
return nil, ErrNotFound
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err)
}
return body, nil
}
func (p *mbcaaProvider) TestConnection(ctx context.Context) error {
_, err := p.FetchAlbumCover(ctx, AlbumRef{MBID: mbcaaTestSampleMBID})
if err == nil {
return nil
}
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}
var _ ReleaseGroupCoverProvider = (*mbcaaProvider)(nil)