Files
minstrel/internal/musicbrainz/client_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

203 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package musicbrainz
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
)
// testClient talks to srv with no pacing and fast retries.
func testClient(srv *httptest.Server) *Client {
return &Client{BaseURL: srv.URL, Pace: &Pacer{}, Backoff: time.Millisecond}
}
func TestEscape_KeepsWhatWasTyped(t *testing.T) {
for in, want := range map[string]string{
"Radiohead": "Radiohead",
"AC/DC": `AC\/DC`,
"Sunn O)))": `Sunn O\)\)\)`,
`"Weird" Al`: `\"Weird\" Al`,
} {
if got := Escape(in); got != want {
t.Errorf("Escape(%q) = %q, want %q", in, got, want)
}
}
}
func TestSearchReleaseGroups_AsksTitleAndArtistAndReadsCredits(t *testing.T) {
var query, agent atomic.Value
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query.Store(r.URL.Query().Get("query"))
agent.Store(r.Header.Get("User-Agent"))
_, _ = w.Write([]byte(`{"release-groups": [{
"id": "rg1", "title": "OK Computer", "primary-type": "Album", "secondary-types": [],
"first-release-date": "1997-05-21",
"artist-credit": [{"name": "Radiohead", "joinphrase": "", "artist": {"id": "a1", "name": "Radiohead"}}]
}]}`))
}))
defer srv.Close()
got, err := testClient(srv).SearchReleaseGroups(context.Background(), "ok computer", 10)
if err != nil {
t.Fatal(err)
}
if q := query.Load(); q != "releasegroup:(ok computer) OR artist:(ok computer)" {
t.Errorf("query = %q", q)
}
if agent.Load() != UserAgent {
t.Errorf("User-Agent = %q", agent.Load())
}
if len(got) != 1 || got[0].MBID != "rg1" || got[0].Year() != "1997" || got[0].ArtistCredit != "Radiohead" || got[0].ArtistMBIDs[0] != "a1" {
t.Errorf("got %+v", got)
}
}
func TestSearchRecordings_FilesASongUnderItsAlbum(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"recordings": [{
"id": "rec1", "title": "Karma Police", "length": 264000,
"artist-credit": [{"name": "Radiohead", "artist": {"id": "a1"}}],
"releases": [
{"id": "r1", "status": "Official", "release-group": {"id": "rg-live", "title": "Live", "primary-type": "Album", "secondary-types": ["Live"]}},
{"id": "r2", "status": "Official", "release-group": {"id": "rg1", "title": "OK Computer", "primary-type": "Album"}}
]
}]}`))
}))
defer srv.Close()
got, err := testClient(srv).SearchRecordings(context.Background(), "karma police", 5)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].ReleaseGroup.MBID != "rg1" || got[0].LengthMs != 264000 {
t.Errorf("got %+v, want it filed under the studio album", got)
}
}
func TestArtistReleaseGroups_PagesUntilTheCount(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if r.URL.Query().Get("artist") != "a1" {
t.Errorf("artist = %q", r.URL.Query().Get("artist"))
}
// 3 in all, two per page.
var items string
for i := offset; i < offset+2 && i < 3; i++ {
if items != "" {
items += ","
}
items += fmt.Sprintf(`{"id": "rg%d", "title": "T%d", "primary-type": "Album"}`, i, i)
}
_, _ = fmt.Fprintf(w, `{"release-group-count": 3, "release-groups": [%s]}`, items)
}))
defer srv.Close()
got, err := testClient(srv).ArtistReleaseGroups(context.Background(), "a1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 || calls.Load() != 2 {
t.Errorf("got %d groups in %d calls, want 3 in 2", len(got), calls.Load())
}
}
func TestReleaseGroupReleases_ReadsMediaAndTracks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("inc") != "media recordings" {
t.Errorf("inc = %q", r.URL.Query().Get("inc"))
}
_, _ = w.Write([]byte(`{"release-count": 1, "releases": [{
"id": "r1", "title": "OK Computer", "status": "Official", "date": "1997-06-16", "country": "GB",
"media": [
{"position": 1, "format": "CD", "tracks": [
{"position": 1, "number": "1", "title": "Airbag", "length": 284000, "recording": {"id": "rec-a"}},
{"position": 2, "number": "2", "title": "Paranoid Android", "length": null, "recording": {"id": "rec-b", "length": 383000}}
]},
{"position": 2, "format": "CD", "tracks": []}
]
}]}`))
}))
defer srv.Close()
got, err := testClient(srv).ReleaseGroupReleases(context.Background(), "rg1")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("got %+v", got)
}
r := got[0]
if r.TrackCount() != 2 || r.Formats() != "2×CD" {
t.Errorf("tracks %d, formats %q", r.TrackCount(), r.Formats())
}
if tr := r.Media[0].Tracks[1]; tr.LengthMs != 383000 || tr.RecordingMBID != "rec-b" {
t.Errorf("a track with no length of its own should take the recording's: %+v", tr)
}
}
func TestGet_ThrottleIsRetriedThenUnreachable(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
_, _ = w.Write([]byte(`{"artists": [{"id": "a1", "name": "Radiohead"}]}`))
}))
defer srv.Close()
got, err := testClient(srv).SearchArtists(context.Background(), "radiohead", 5)
if err != nil || len(got) != 1 || calls.Load() != 2 {
t.Fatalf("a throttled first answer should be retried: %v, %+v, %d calls", err, got, calls.Load())
}
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer down.Close()
if _, err := testClient(down).SearchArtists(context.Background(), "x", 5); !errors.Is(err, ErrUnreachable) {
t.Errorf("err = %v, want ErrUnreachable", err)
}
}
func TestGet_NotFoundAndNoServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
if _, err := testClient(srv).Artist(context.Background(), "nope"); !errors.Is(err, ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
srv.Close()
c := testClient(srv)
c.Retries = -1
if _, err := c.Artist(context.Background(), "a1"); !errors.Is(err, ErrUnreachable) {
t.Errorf("a closed server: err = %v, want ErrUnreachable", err)
}
}
func TestPacer_SpacesCallers(t *testing.T) {
p := &Pacer{Interval: 30 * time.Millisecond}
start := time.Now()
for range 3 {
if err := p.Wait(context.Background()); err != nil {
t.Fatal(err)
}
}
if elapsed := time.Since(start); elapsed < 60*time.Millisecond {
t.Errorf("three waits took %v, want at least two intervals", elapsed)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := p.Wait(ctx); err == nil {
t.Error("a cancelled wait should return the context's error")
}
}