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
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>
254 lines
9.4 KiB
Go
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")
|
|
}
|
|
}
|