Files
bvandeusenandClaude Opus 5.5 b887ab759b
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
The one-time listing took only albums whose tags already named their
release group and artist, and left 340 off the list. The rest are now
matched: the release their tags name, else the artist's release groups
called what the album is called, else a MusicBrainz search by artist and
title. A group found by name is taken only when its tracks agree with the
album's (by disc and number, then title; length and title, or recording),
so a partial copy still matches and a same-titled other album does not.

library_album_matches records each album the listing decided, listed or
unmatched with why, so an album taken off the list is never re-listed. The
albums no match was found for are named on the Held tab. Album rows with
no tracks are no longer counted.

The length and title checks move from the importer to release, shared by
both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:16:05 -04:00

224 lines
7.5 KiB
Go
Raw Permalink 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 TestSearchReleaseGroupsBy_AsksForBothTitleAndArtist(t *testing.T) {
var query atomic.Value
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
query.Store(r.URL.Query().Get("query"))
_, _ = w.Write([]byte(`{"release-groups": [{"id": "rg1", "title": "Highway to Hell",
"artist-credit": [{"name": "AC/DC", "artist": {"id": "a1"}}]}]}`))
}))
defer srv.Close()
got, err := testClient(srv).SearchReleaseGroupsBy(context.Background(), " AC/DC ", "Highway to Hell", 10)
if err != nil {
t.Fatal(err)
}
if q := query.Load(); q != `releasegroup:(Highway to Hell) AND artist:(AC\/DC)` {
t.Errorf("query = %q", q)
}
if len(got) != 1 || got[0].MBID != "rg1" || 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")
}
}