Files
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

166 lines
5.8 KiB
Go

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)
}
}