Files
minstrel/internal/catalogue/catalogue_test.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

248 lines
8.7 KiB
Go

package catalogue
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
const (
gorillaz = "e21857d5-3256-4547-afb3-4b6ded592596"
deLaSoul = "4f2a6b9e-9ae6-4f34-9a41-3e9f2f7c4c1d"
demonDays = "0c4d5b5f-2f3c-3d31-9c4e-4b4f6b4c1a01"
feelGood = "0c4d5b5f-2f3c-3d31-9c4e-4b4f6b4c1a02" // credited to both
demonDaysR = "1a2b3c4d-0000-4000-8000-000000000001"
demonDaysJ = "1a2b3c4d-0000-4000-8000-000000000002"
)
// fakeMB is a MusicBrainz with two artists, a collaboration between them,
// and one album with two editions. down makes every request a 503.
type fakeMB struct {
srv *httptest.Server
calls atomic.Int32
down atomic.Bool
}
func newFakeMB(t *testing.T) *fakeMB {
t.Helper()
f := &fakeMB{}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.calls.Add(1)
if f.down.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
q := r.URL.Query()
switch {
case r.URL.Path == "/artist/"+gorillaz:
_, _ = fmt.Fprintf(w, `{"id": %q, "name": "Gorillaz", "sort-name": "Gorillaz", "type": "Group", "country": "GB"}`, gorillaz)
case r.URL.Path == "/artist/"+deLaSoul:
_, _ = fmt.Fprintf(w, `{"id": %q, "name": "De La Soul", "type": "Group", "country": "US"}`, deLaSoul)
case r.URL.Path == "/release-group" && q.Get("artist") == gorillaz:
_, _ = fmt.Fprintf(w, `{"release-group-count": 2, "release-groups": [
{"id": %q, "title": "Demon Days", "primary-type": "Album", "first-release-date": "2005-05-11",
"artist-credit": [{"name": "Gorillaz", "artist": {"id": %q}}]},
{"id": %q, "title": "Feel Good Inc.", "primary-type": "Single", "secondary-types": [], "first-release-date": "2005-05-09",
"artist-credit": [{"name": "Gorillaz", "joinphrase": " feat. ", "artist": {"id": %q}}, {"name": "De La Soul", "artist": {"id": %q}}]}
]}`, demonDays, gorillaz, feelGood, gorillaz, deLaSoul)
case r.URL.Path == "/release-group" && q.Get("artist") == deLaSoul:
_, _ = fmt.Fprintf(w, `{"release-group-count": 1, "release-groups": [
{"id": %q, "title": "Feel Good Inc.", "primary-type": "Single", "first-release-date": "2005-05-09"}
]}`, feelGood)
case r.URL.Path == "/release-group/"+demonDays:
_, _ = fmt.Fprintf(w, `{"id": %q, "title": "Demon Days", "primary-type": "Album", "first-release-date": "2005-05-11",
"artist-credit": [{"name": "Gorillaz", "artist": {"id": %q}}]}`, demonDays, gorillaz)
case r.URL.Path == "/release" && q.Get("release-group") == demonDays:
_, _ = fmt.Fprintf(w, `{"release-count": 2, "releases": [
{"id": %q, "title": "Demon Days", "status": "Promotion", "date": "2005-04", "country": "JP",
"media": [{"position": 1, "format": "CD", "tracks": [
{"position": 1, "number": "1", "title": "Intro", "length": 63000, "recording": {"id": "rec-intro"}}]}]},
{"id": %q, "title": "Demon Days", "status": "Official", "date": "2005-05-11", "country": "GB",
"media": [{"position": 1, "format": "CD", "tracks": [
{"position": 1, "number": "1", "title": "Intro", "length": 63000, "recording": {"id": "rec-intro"}},
{"position": 2, "number": "2", "title": "Last Living Souls", "length": 190000, "recording": {"id": "rec-lls"}}]}]}
]}`, demonDaysJ, demonDaysR)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(f.srv.Close)
return f
}
func (f *fakeMB) client() *musicbrainz.Client {
return &musicbrainz.Client{BaseURL: f.srv.URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
}
func TestDiscography_ReadsOnceThenServesTheHeldCopy_Integration(t *testing.T) {
ctx := context.Background()
mb := newFakeMB(t)
s := New(newPool(t), mb.client(), nil)
d, err := s.Discography(ctx, gorillaz)
if err != nil {
t.Fatal(err)
}
if d.Artist.Name != "Gorillaz" || d.Artist.Country != "GB" || len(d.Groups) != 2 || d.Stale {
t.Fatalf("discography = %+v", d)
}
// Oldest first: the single came out two days before the album.
if d.Groups[0].Mbid != feelGood || d.Groups[1].PrimaryType != "Album" {
t.Errorf("order = %s, %s", d.Groups[0].Title, d.Groups[1].Title)
}
if d.Groups[0].ArtistCredit != "Gorillaz feat. De La Soul" {
t.Errorf("credit = %q", d.Groups[0].ArtistCredit)
}
before := mb.calls.Load()
if _, err := s.Discography(ctx, gorillaz); err != nil {
t.Fatal(err)
}
if mb.calls.Load() != before {
t.Error("a fresh discography should be served without asking MusicBrainz")
}
// The collaboration belongs to both artists.
dls, err := s.Discography(ctx, deLaSoul)
if err != nil {
t.Fatal(err)
}
if len(dls.Groups) != 1 || dls.Groups[0].Mbid != feelGood {
t.Errorf("De La Soul's discography = %+v", dls.Groups)
}
if again, _ := s.Discography(ctx, gorillaz); len(again.Groups) != 2 {
t.Error("refreshing one artist must not take a shared group from the other")
}
}
func TestDiscography_UnreachableServesStaleOrFails_Integration(t *testing.T) {
ctx := context.Background()
mb := newFakeMB(t)
pool := newPool(t)
s := New(pool, mb.client(), nil)
mb.down.Store(true)
if _, err := s.Discography(ctx, gorillaz); !errors.Is(err, musicbrainz.ErrUnreachable) {
t.Fatalf("nothing held and MusicBrainz down: err = %v, want ErrUnreachable", err)
}
mb.down.Store(false)
if _, err := s.Discography(ctx, gorillaz); err != nil {
t.Fatal(err)
}
// A week and a day later, MusicBrainz is down: the held copy is served.
s.now = func() time.Time { return time.Now().Add(DiscographyMaxAge + 24*time.Hour) }
mb.down.Store(true)
d, err := s.Discography(ctx, gorillaz)
if err != nil || !d.Stale || len(d.Groups) != 2 {
t.Fatalf("stale copy: %+v, %v", d, err)
}
}
func TestDiscography_UnknownArtistIsNotFound_Integration(t *testing.T) {
s := New(newPool(t), newFakeMB(t).client(), nil)
if _, err := s.Discography(context.Background(), "00000000-0000-4000-8000-000000000000"); !errors.Is(err, ErrNotFound) {
t.Fatalf("err = %v, want ErrNotFound", err)
}
}
func TestReleases_ReadsEditionsAndTrackLists_Integration(t *testing.T) {
ctx := context.Background()
mb := newFakeMB(t)
s := New(newPool(t), mb.client(), nil)
// The group isn't held yet: it is looked up first.
e, err := s.Releases(ctx, demonDays)
if err != nil {
t.Fatal(err)
}
if e.Group.Title != "Demon Days" || len(e.Editions) != 2 {
t.Fatalf("editions = %+v", e)
}
official := e.Editions[0]
if official.Release.Mbid != demonDaysR || official.Release.TrackCount != 2 || official.Release.Formats != "CD" {
t.Errorf("the official edition should come first: %+v", official.Release)
}
if len(official.Tracks) != 2 || official.Tracks[1].RecordingMbid != "rec-lls" || official.Tracks[1].LengthMs != 190000 {
t.Errorf("tracks = %+v", official.Tracks)
}
before := mb.calls.Load()
if _, err := s.Releases(ctx, demonDays); err != nil {
t.Fatal(err)
}
if mb.calls.Load() != before {
t.Error("fresh editions should be served without asking MusicBrainz")
}
// Refreshing replaces rather than adds.
if err := s.RefreshReleaseGroup(ctx, demonDays); err != nil {
t.Fatal(err)
}
if again, _ := s.Releases(ctx, demonDays); len(again.Editions) != 2 || len(again.Editions[0].Tracks) != 2 {
t.Errorf("after a refresh: %+v", again)
}
}
func TestRefreshDue_ReadsWhatIsOldAndStopsWhenMusicBrainzIsDown_Integration(t *testing.T) {
ctx := context.Background()
mb := newFakeMB(t)
s := New(newPool(t), mb.client(), nil)
for _, a := range []string{gorillaz, deLaSoul} {
if err := s.RefreshArtist(ctx, a); err != nil {
t.Fatal(err)
}
}
if n, err := s.RefreshDue(ctx, 10); err != nil || n != 0 {
t.Fatalf("nothing is due yet: %d, %v", n, err)
}
s.now = func() time.Time { return time.Now().Add(DiscographyMaxAge + time.Hour) }
if n, err := s.RefreshDue(ctx, 1); err != nil || n != 1 {
t.Errorf("one due, limit one: %d, %v", n, err)
}
mb.down.Store(true)
n, err := s.RefreshDue(ctx, 10)
if !errors.Is(err, musicbrainz.ErrUnreachable) || n != 0 {
t.Errorf("MusicBrainz down: %d, %v", n, err)
}
if !strings.Contains(err.Error(), "503") {
t.Errorf("the error should say what MusicBrainz answered: %v", err)
}
}