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
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>
174 lines
4.9 KiB
Go
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)
|