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>
248 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|