feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
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>
This commit is contained in:
2026-10-09 21:08:22 -04:00
co-authored by Claude Opus 5.5
parent a2e1890d4c
commit 77389f88bc
40 changed files with 3223 additions and 577 deletions
+165
View File
@@ -0,0 +1,165 @@
package catalogue
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
)
const someMBID = "e21857d5-3256-4547-afb3-4b6ded592596"
// fakeSource is an artist and album source answering with fixed results.
type fakeSource struct {
thumb, fanart, cover []byte
err error
calls atomic.Int32
gate chan struct{} // when set, each fetch waits for it
lastRef coverart.AlbumRef
mu sync.Mutex
}
func (f *fakeSource) ID() string { return "fake" }
func (f *fakeSource) DisplayName() string { return "Fake" }
func (f *fakeSource) RequiresAPIKey() bool { return false }
func (f *fakeSource) DefaultEnabled() bool { return true }
func (f *fakeSource) Configure(_ coverart.ProviderSettings) error { return nil }
func (f *fakeSource) wait() {
f.calls.Add(1)
if f.gate != nil {
<-f.gate
}
}
func (f *fakeSource) FetchArtistArt(_ context.Context, _ coverart.ArtistRef) ([]byte, []byte, error) {
f.wait()
return f.thumb, f.fanart, f.err
}
func (f *fakeSource) FetchAlbumCover(_ context.Context, ref coverart.AlbumRef) ([]byte, error) {
f.wait()
f.mu.Lock()
f.lastRef = ref
f.mu.Unlock()
return f.cover, f.err
}
// groupSource can also answer by release group, as the Cover Art Archive does.
type groupSource struct{ *fakeSource }
func (g groupSource) FetchReleaseGroupCover(_ context.Context, _ string) ([]byte, error) {
g.wait()
return []byte("by-group"), nil
}
type sources struct {
artists []coverart.ArtistArtProvider
albums []coverart.AlbumCoverProvider
}
func (s sources) EnabledArtistProviders() []coverart.ArtistArtProvider { return s.artists }
func (s sources) EnabledAlbumProviders() []coverart.AlbumCoverProvider { return s.albums }
func TestArt_FetchesOnceAndKeepsIt(t *testing.T) {
src := &fakeSource{fanart: []byte("backdrop")}
a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}})
body, err := a.Artist(context.Background(), someMBID, "Gorillaz")
if err != nil || string(body) != "backdrop" {
t.Fatalf("with only a backdrop, use it: %q, %v", body, err)
}
if body, _ := a.Artist(context.Background(), someMBID, "Gorillaz"); string(body) != "backdrop" || src.calls.Load() != 1 {
t.Errorf("the second ask should come from disk: %q after %d calls", body, src.calls.Load())
}
}
func TestArt_NobodyHasOneIsBelievedForAWeek(t *testing.T) {
src := &fakeSource{err: coverart.ErrNotFound}
a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}})
if _, err := a.Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) {
t.Fatalf("err = %v, want ErrNoArt", err)
}
if _, err := a.Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) || src.calls.Load() != 1 {
t.Errorf("a definite miss is remembered: %d calls", src.calls.Load())
}
a.now = func() time.Time { return time.Now().Add(NoArtRetry + time.Hour) }
src.err, src.thumb = nil, []byte("thumb")
if body, err := a.Artist(context.Background(), someMBID, "x"); err != nil || string(body) != "thumb" {
t.Errorf("after a week the sources are asked again: %q, %v", body, err)
}
}
func TestArt_AnUnreachableSourceIsAskedAgain(t *testing.T) {
src := &fakeSource{err: coverart.ErrTransient}
a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}})
for range 2 {
if _, err := a.Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) {
t.Fatalf("err = %v", err)
}
}
if src.calls.Load() != 2 {
t.Errorf("calls = %d: an outage must not be written down as 'no art'", src.calls.Load())
}
}
func TestArt_ReleaseGroupAsksByGroupThenByName(t *testing.T) {
byName := &fakeSource{cover: []byte("by-name")}
caa := groupSource{&fakeSource{}}
a := NewArt(t.TempDir(), sources{albums: []coverart.AlbumCoverProvider{caa, byName}})
if body, _ := a.ReleaseGroup(context.Background(), someMBID, "Gorillaz", "Demon Days"); string(body) != "by-group" {
t.Errorf("a source that answers by group is asked by group: %q", body)
}
other := "0c4d5b5f-2f3c-3d31-9c4e-4b4f6b4c1a01"
a = NewArt(t.TempDir(), sources{albums: []coverart.AlbumCoverProvider{byName}})
if body, _ := a.ReleaseGroup(context.Background(), other, "Gorillaz", "Demon Days"); string(body) != "by-name" {
t.Errorf("by name: %q", body)
}
if byName.lastRef.MBID != "" || byName.lastRef.AlbumTitle != "Demon Days" {
t.Errorf("a release-group id is not a release id, so it is not passed as one: %+v", byName.lastRef)
}
}
func TestArt_ConcurrentAsksShareOneFetch(t *testing.T) {
src := &fakeSource{thumb: []byte("t"), gate: make(chan struct{})}
a := NewArt(t.TempDir(), sources{artists: []coverart.ArtistArtProvider{src}})
var wg sync.WaitGroup
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
if body, err := a.Artist(context.Background(), someMBID, "x"); err != nil || string(body) != "t" {
t.Errorf("%q, %v", body, err)
}
}()
}
// Let the fetch through once every caller has had time to queue on it.
time.Sleep(50 * time.Millisecond)
close(src.gate)
wg.Wait()
if src.calls.Load() != 1 {
t.Errorf("calls = %d, want one fetch for five asks", src.calls.Load())
}
}
func TestArt_RefusesWhatIsNotAnMBID(t *testing.T) {
dir := t.TempDir()
a := NewArt(dir, sources{artists: []coverart.ArtistArtProvider{&fakeSource{thumb: []byte("t")}}})
if _, err := a.Artist(context.Background(), "../../etc/passwd", "x"); !errors.Is(err, ErrNoArt) {
t.Fatalf("err = %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "catalogue-art")); !os.IsNotExist(err) {
t.Error("nothing should be written for a refused id")
}
if _, err := NewArt(dir, nil).Artist(context.Background(), someMBID, "x"); !errors.Is(err, ErrNoArt) {
t.Errorf("no sources: err = %v", err)
}
}