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>
254 lines
9.5 KiB
Go
254 lines
9.5 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.CreateLidarrRequest(ctx, dbq.CreateLidarrRequestParams{
|
|
UserID: user.ID, Kind: dbq.LidarrRequestKindTrack, LidarrArtistMbid: searchArtistMBID,
|
|
LidarrTrackMbid: &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")
|
|
}
|
|
}
|