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>
196 lines
7.1 KiB
Go
196 lines
7.1 KiB
Go
package coverart
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
)
|
|
|
|
// Provider is the base shape every cover-art source implements.
|
|
// Providers register themselves at init() time via Register(). The
|
|
// enricher reads the current per-provider settings and dispatches to
|
|
// the capability methods below.
|
|
type Provider interface {
|
|
// ID is a stable lowercase token persisted in DB columns
|
|
// (cover_art_source / artist_art_source) and the
|
|
// cover_art_provider_settings.provider_id key, plus referenced
|
|
// in the operator-facing UI.
|
|
ID() string
|
|
|
|
// DisplayName is the human-readable label shown in the admin
|
|
// Settings card.
|
|
DisplayName() string
|
|
|
|
// RequiresAPIKey reports whether the provider needs a key to
|
|
// function. True for theaudiodb / fanart.tv; false for mbcaa.
|
|
// The admin UI uses this to show or hide the api_key text field;
|
|
// it does NOT gate the enabled toggle (per the no-coercive-
|
|
// settings principle, providers with usable defaults — such as
|
|
// TheAudioDB's public test key — ship working).
|
|
RequiresAPIKey() bool
|
|
|
|
// DefaultEnabled is whether a freshly-installed Minstrel ships
|
|
// with this provider on. Both v1 providers return true.
|
|
DefaultEnabled() bool
|
|
|
|
// Configure receives the operator's settings (api_key, enabled
|
|
// flag) and applies them. Called at boot during reconciliation
|
|
// and on every admin PATCH. Implementations must be idempotent
|
|
// and goroutine-safe (the enricher may be reading concurrently).
|
|
Configure(ProviderSettings) error
|
|
}
|
|
|
|
// ProviderSettings is the runtime config a provider receives when
|
|
// Configure() is called. Providers may interpret an empty APIKey
|
|
// however they wish — TheAudioDB falls back to its public test key,
|
|
// MBCAA ignores the field entirely.
|
|
type ProviderSettings struct {
|
|
Enabled bool
|
|
APIKey string
|
|
}
|
|
|
|
// ArtistRef is the lookup key passed to ArtistArtProvider.FetchArtistArt.
|
|
// MBID is preferred when present; Name is the always-populated fallback
|
|
// for providers that support name-based search (Deezer, Last.fm).
|
|
// MBID-only providers (TheAudioDB) return ErrNotFound when MBID is empty.
|
|
type ArtistRef struct {
|
|
MBID string
|
|
Name string
|
|
}
|
|
|
|
// AlbumRef is the lookup key passed to AlbumCoverProvider.FetchAlbumCover.
|
|
// Same MBID-preferred semantics as ArtistRef; ArtistName + AlbumTitle
|
|
// support name-based providers.
|
|
type AlbumRef struct {
|
|
MBID string
|
|
ArtistName string
|
|
AlbumTitle string
|
|
}
|
|
|
|
// AlbumCoverProvider is an opt-in capability: providers implementing
|
|
// it can fetch album cover art. MBID-only providers use ref.MBID and
|
|
// return ErrNotFound when it is empty; name-based providers (Deezer,
|
|
// Last.fm) fall back to ref.ArtistName + ref.AlbumTitle.
|
|
type AlbumCoverProvider interface {
|
|
Provider
|
|
// FetchAlbumCover returns the image bytes for the given album ref,
|
|
// or ErrNotFound (terminal — no art available) /
|
|
// ErrTransient (retry-eligible failure).
|
|
FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error)
|
|
}
|
|
|
|
// ReleaseGroupCoverProvider is an opt-in capability for sources that can
|
|
// answer by MusicBrainz release group, which is how the release catalogue
|
|
// names an album before any edition of it is held (M502 #5519).
|
|
type ReleaseGroupCoverProvider interface {
|
|
AlbumCoverProvider
|
|
FetchReleaseGroupCover(ctx context.Context, groupMBID string) ([]byte, error)
|
|
}
|
|
|
|
// ArtistArtProvider is an opt-in capability: providers implementing
|
|
// it can fetch artist images. Returns thumb + fanart atomically at
|
|
// the JSON metadata step (one round-trip yields both URLs); the two
|
|
// image GETs are independent — partial success returns whatever bytes
|
|
// landed and the source is still stamped, so the enricher persists
|
|
// asymmetric results (thumb-only or fanart-only) gracefully.
|
|
type ArtistArtProvider interface {
|
|
Provider
|
|
FetchArtistArt(ctx context.Context, ref ArtistRef) (thumb, fanart []byte, err error)
|
|
}
|
|
|
|
// TestableProvider is an opt-in capability: providers that can answer
|
|
// "is my config working?" without performing a full enrichment cycle.
|
|
// Used by the admin Test-Connection button. v1: both MBCAA and
|
|
// TheAudioDB implement this.
|
|
type TestableProvider interface {
|
|
Provider
|
|
TestConnection(ctx context.Context) error
|
|
}
|
|
|
|
// ErrNotTestable is returned by the SettingsService.TestProvider
|
|
// method when the named provider doesn't implement TestableProvider.
|
|
var ErrNotTestable = errors.New("coverart: provider does not support test connection")
|
|
|
|
// ErrProviderNotFound is returned when the named provider is not
|
|
// registered.
|
|
var ErrProviderNotFound = errors.New("coverart: provider not registered")
|
|
|
|
// ErrNotFound indicates the upstream confirmed there is no art for
|
|
// this MBID — a terminal-this-pass result. The enricher treats this
|
|
// as "try the next provider in the chain"; if all providers return
|
|
// ErrNotFound, the row settles to cover_art_source='none'.
|
|
//
|
|
// Aliased to apierror.ErrNotFound so handler error mapping (errors.Is
|
|
// against the shared sentinel) and existing per-package callsites both
|
|
// resolve to the same pointer.
|
|
var ErrNotFound = apierror.ErrNotFound
|
|
|
|
// ErrTransient indicates a temporary failure (5xx, network, timeout,
|
|
// auth issue). The enricher leaves the row's source NULL so the next
|
|
// scan pass can retry.
|
|
var ErrTransient = errors.New("coverart: transient error")
|
|
|
|
// registry is the package-private list of all compiled-in providers.
|
|
// Providers register at init() via Register(). The enricher iterates
|
|
// over this list filtered by the current per-provider Settings.
|
|
var registry = struct {
|
|
mu sync.Mutex
|
|
providers []Provider
|
|
}{}
|
|
|
|
// Register adds a provider to the registry. Must be called from
|
|
// init() of a provider file (provider_mbcaa.go, provider_theaudiodb.go,
|
|
// etc.). Calling twice with the same ID panics — providers are
|
|
// compiled-in, duplicate registration is a build-time bug.
|
|
func Register(p Provider) {
|
|
registry.mu.Lock()
|
|
defer registry.mu.Unlock()
|
|
for _, existing := range registry.providers {
|
|
if existing.ID() == p.ID() {
|
|
panic(fmt.Sprintf("coverart: duplicate provider ID %q", p.ID()))
|
|
}
|
|
}
|
|
registry.providers = append(registry.providers, p)
|
|
}
|
|
|
|
// AllProviders returns a snapshot of all registered providers in
|
|
// registration order. Caller must not mutate the returned slice.
|
|
func AllProviders() []Provider {
|
|
registry.mu.Lock()
|
|
defer registry.mu.Unlock()
|
|
out := make([]Provider, len(registry.providers))
|
|
copy(out, registry.providers)
|
|
return out
|
|
}
|
|
|
|
// ProviderByID looks up a registered provider by ID. Returns
|
|
// ErrProviderNotFound if no such provider has registered.
|
|
func ProviderByID(id string) (Provider, error) {
|
|
registry.mu.Lock()
|
|
defer registry.mu.Unlock()
|
|
for _, p := range registry.providers {
|
|
if p.ID() == id {
|
|
return p, nil
|
|
}
|
|
}
|
|
return nil, ErrProviderNotFound
|
|
}
|
|
|
|
// resetRegistryForTests clears the registry. Test-only helper
|
|
// (provider_test.go); never called from production code.
|
|
func resetRegistryForTests() {
|
|
registry.mu.Lock()
|
|
defer registry.mu.Unlock()
|
|
registry.providers = nil
|
|
}
|
|
|
|
// ResetRegistryForTests is an exported wrapper around resetRegistryForTests
|
|
// for use by tests in other packages (e.g. internal/api). Never call from
|
|
// production code.
|
|
func ResetRegistryForTests() {
|
|
resetRegistryForTests()
|
|
}
|