Files
minstrel/internal/api/catalogue_test.go
T
bvandeusenandClaude Opus 5.5 04515a977c
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:46:22 -04:00

254 lines
9.4 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
)
const (
searchArtistMBID = "a74b1b7f-71a5-4011-9441-d0b5e4122711"
searchGroupMBID = "b1392450-e666-3926-a536-22c65f834433"
searchRecMBID = "c1a0e1b2-0000-4000-8000-000000000001"
)
// fakeMusicBrainz answers the three searches with one Radiohead result each,
// or 503 when down is set.
func fakeMusicBrainz(t *testing.T, down bool) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
credit := `[{"name": "Radiohead", "artist": {"id": "` + searchArtistMBID + `"}}]`
group := `{"id": "` + searchGroupMBID + `", "title": "OK Computer", "primary-type": "Album",
"first-release-date": "1997-05-21", "artist-credit": ` + credit + `}`
switch r.URL.Path {
case "/artist":
_, _ = w.Write([]byte(`{"artists": [{"id": "` + searchArtistMBID + `", "name": "Radiohead", "type": "Group", "country": "GB"}]}`))
case "/release-group":
_, _ = w.Write([]byte(`{"release-groups": [` + group + `]}`))
case "/recording":
_, _ = w.Write([]byte(`{"recordings": [{"id": "` + searchRecMBID + `", "title": "Karma Police",
"artist-credit": ` + credit + `,
"releases": [{"id": "r1", "status": "Official", "release-group": ` + group + `}]}]}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
func catalogueHandlers(t *testing.T, down bool) *handlers {
t.Helper()
h, pool := testHandlers(t)
resetLidarrState(t, h)
mb := &musicbrainz.Client{BaseURL: fakeMusicBrainz(t, down).URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
h.catalogue = catalogue.New(pool, mb, h.logger)
return h
}
func doCatalogueSearch(t *testing.T, h *handlers, q, kind string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/catalogue/search?"+url.Values{"q": {q}, "kind": {kind}}.Encode(), nil)
w := httptest.NewRecorder()
h.handleCatalogueSearch(w, req)
return w
}
func decodeResults(t *testing.T, w *httptest.ResponseRecorder) []catalogueSearchResult {
t.Helper()
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var out []catalogueSearchResult
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
return out
}
func errorCode(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
return resp.Error.Code
}
func TestCatalogueSearch_RefusesABadKindOrNoQuery(t *testing.T) {
h := catalogueHandlers(t, false)
if w := doCatalogueSearch(t, h, "x", "playlist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "bad_kind" {
t.Errorf("bad kind: %d %s", w.Code, w.Body.String())
}
if w := doCatalogueSearch(t, h, " ", "artist"); w.Code != http.StatusBadRequest || errorCode(t, w) != "missing_query" {
t.Errorf("no query: %d %s", w.Code, w.Body.String())
}
}
func TestCatalogueSearch_EachKindAsksMusicBrainz(t *testing.T) {
h := catalogueHandlers(t, false)
artists := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist"))
if len(artists) != 1 || artists[0].MBID != searchArtistMBID || artists[0].SecondaryText != "Group · GB" {
t.Fatalf("artists = %+v", artists)
}
if artists[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Radiohead" {
t.Errorf("artist art = %q: it must be Minstrel's own endpoint", artists[0].ImageURL)
}
albums := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album"))
if len(albums) != 1 || albums[0].MBID != searchGroupMBID || albums[0].ArtistMBID != searchArtistMBID ||
albums[0].SecondaryText != "Radiohead · 1997 · Album" {
t.Fatalf("albums = %+v", albums)
}
tracks := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track"))
if len(tracks) != 1 || tracks[0].AlbumMBID != searchGroupMBID || tracks[0].ArtistMBID != searchArtistMBID ||
tracks[0].SecondaryText != "OK Computer · Radiohead" {
t.Fatalf("tracks = %+v", tracks)
}
}
func TestCatalogueSearch_MarksWhatIsHeldAndWhatIsRequested(t *testing.T) {
h := catalogueHandlers(t, false)
ctx := context.Background()
q := dbq.New(h.pool)
artistMBID := searchArtistMBID
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: dbtest.TestUserPrefix + "Radiohead", SortName: "Radiohead", Mbid: &artistMBID})
if err != nil {
t.Fatal(err)
}
// The album is held under a release id; its release group is what matches.
if _, err := h.pool.Exec(ctx,
`INSERT INTO albums (title, sort_title, artist_id, mbid, release_group_mbid) VALUES ('OK Computer', 'OK Computer', $1, 'some-release-id', $2)`,
artist.ID, searchGroupMBID); err != nil {
t.Fatal(err)
}
if got := decodeResults(t, doCatalogueSearch(t, h, "radiohead", "artist")); !got[0].InLibrary {
t.Error("the artist is held")
}
if got := decodeResults(t, doCatalogueSearch(t, h, "ok computer", "album")); !got[0].InLibrary {
t.Error("the album is held, matched by release group")
}
user := seedUser(t, h.pool, "requester", "pw", false)
track := searchRecMBID
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: user.ID, Kind: dbq.RequestKindTrack, ArtistMbid: searchArtistMBID,
TrackMbid: &track, ArtistName: "Radiohead",
}); err != nil {
t.Fatal(err)
}
got := decodeResults(t, doCatalogueSearch(t, h, "karma police", "track"))
if got[0].InLibrary || !got[0].Requested {
t.Errorf("track = %+v, want requested and not held", got[0])
}
}
func TestCatalogueSearch_UnreachableMusicBrainzIs503(t *testing.T) {
h := catalogueHandlers(t, true)
w := doCatalogueSearch(t, h, "radiohead", "artist")
if w.Code != http.StatusServiceUnavailable || errorCode(t, w) != "musicbrainz_unreachable" {
t.Fatalf("status %d, body %s", w.Code, w.Body.String())
}
}
// artSource is an artist-art source with one image.
type artSource struct{ apiTestAlbumProvider }
func (artSource) FetchArtistArt(_ context.Context, _ coverart.ArtistRef) ([]byte, []byte, error) {
return []byte("\x89PNG\r\n\x1a\nrest"), nil, nil
}
type fakeArtSources struct{ artists []coverart.ArtistArtProvider }
func (s fakeArtSources) EnabledArtistProviders() []coverart.ArtistArtProvider { return s.artists }
func (fakeArtSources) EnabledAlbumProviders() []coverart.AlbumCoverProvider { return nil }
func TestCatalogueArt_ServesTheImageOrAPlaceholder404(t *testing.T) {
h := &handlers{catalogueArt: catalogue.NewArt(t.TempDir(), fakeArtSources{artists: []coverart.ArtistArtProvider{&artSource{}}})}
r := chi.NewRouter()
r.Get("/api/catalogue/art/{kind}/{mbid}", h.handleCatalogueArt)
get := func(path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
if w := get("/api/catalogue/art/artist/" + searchArtistMBID + "?name=Radiohead"); w.Code != http.StatusOK || w.Header().Get("Content-Type") != "image/png" {
t.Errorf("artist: %d %q", w.Code, w.Header().Get("Content-Type"))
}
// No album source is enabled: a 404, which the page shows as its placeholder.
if w := get("/api/catalogue/art/release-group/" + searchGroupMBID); w.Code != http.StatusNotFound {
t.Errorf("release group with no source: %d", w.Code)
}
for _, bad := range []string{"/api/catalogue/art/artist/not-an-id", "/api/catalogue/art/label/" + searchArtistMBID} {
if w := get(bad); w.Code != http.StatusNotFound {
t.Errorf("%s: %d", bad, w.Code)
}
}
}
func TestSuggestionArt_PointsAtMinstrelsOwnEndpoint(t *testing.T) {
views := []suggestionView{{MBID: searchArtistMBID, Name: "Sigur Rós"}, {Name: "No MBID"}}
resolveSuggestionArt(views)
if views[0].ImageURL != "/api/catalogue/art/artist/"+searchArtistMBID+"?name=Sigur+R%C3%B3s" {
t.Errorf("image_url = %q", views[0].ImageURL)
}
if views[1].ImageURL != "" {
t.Errorf("no MBID, no image: %q", views[1].ImageURL)
}
}
func TestSearchCache_ReusesAnAnswerButNotTheFlags(t *testing.T) {
c := newSearchCache()
now := time.Now()
c.now = func() time.Time { return now }
asks := 0
ask := func(context.Context, string, string) ([]catalogueSearchResult, error) {
asks++
return []catalogueSearchResult{{MBID: "m"}}, nil
}
first, _ := c.get(context.Background(), "artist", "Radiohead", ask)
first[0].InLibrary = true // the handler sets flags on what it is given
again, _ := c.get(context.Background(), "artist", "radiohead", ask)
if asks != 1 {
t.Errorf("asks = %d: the same words in another case are the same search", asks)
}
if again[0].InLibrary {
t.Error("a flag set on one answer leaked into the cache")
}
if _, _ = c.get(context.Background(), "album", "radiohead", ask); asks != 2 {
t.Error("another kind is another search")
}
now = now.Add(catalogueSearchTTL + time.Second)
if _, _ = c.get(context.Background(), "artist", "radiohead", ask); asks != 3 {
t.Error("an old answer is asked again")
}
var none *searchCache
if _, _ = none.get(context.Background(), "artist", "x", ask); asks != 4 {
t.Error("a nil cache asks every time")
}
}