From 1e37e680d75686f6eadb1f4e7bdbca54030485c7 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 7 Feb 2026 18:19:43 +0000 Subject: [PATCH] feat(agents): Add artist url and top and similar songs to ListenBrainz agent (#4934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): Add artist url and top songs to ListenBrainz agent * add newline at end of file * respond to some feedback * add more tests, include more metadata in top songs * add duration to album info * add similar artists from labs * add similar artists and track radio * fix(client): replace sort with slices.SortFunc for deterministic ordering of recordings with same score Signed-off-by: Deluan * fix: typos Signed-off-by: Deluan * refactor: use struct literal initialization consistently Signed-off-by: Deluan * feat: configurable artist and track algorithms Signed-off-by: Deluan * test configuration changes --------- Signed-off-by: Deluan Co-authored-by: Deluan Quintão Signed-off-by: Deluan --- adapters/listenbrainz/agent.go | 119 +++++- adapters/listenbrainz/agent_test.go | 278 ++++++++++++++ adapters/listenbrainz/client.go | 207 ++++++++++- adapters/listenbrainz/client_test.go | 344 ++++++++++++++++++ conf/configuration.go | 10 +- consts/consts.go | 4 + ...listenbrainz.artist.metadata.homepage.json | 19 + ...tenbrainz.artist.metadata.no_homepage.json | 15 + .../listenbrainz.labs.similar-artists.json | 1 + ....similar-recordings-real-out-of-order.json | 1 + .../listenbrainz.labs.similar-recordings.json | 1 + tests/fixtures/listenbrainz.popularity.json | 81 +++++ 12 files changed, 1072 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/listenbrainz.artist.metadata.homepage.json create mode 100644 tests/fixtures/listenbrainz.artist.metadata.no_homepage.json create mode 100644 tests/fixtures/listenbrainz.labs.similar-artists.json create mode 100644 tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json create mode 100644 tests/fixtures/listenbrainz.labs.similar-recordings.json create mode 100644 tests/fixtures/listenbrainz.popularity.json diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 769b0f5a6..019c6e9f4 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo return err == nil && sk != "" } +func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { + if mbid == "" { + return "", agents.ErrNotFound + } + + url, err := l.client.getArtistUrl(ctx, mbid) + if err != nil { + return "", err + } + return url, nil +} + +func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { + resp, err := l.client.getArtistTopSongs(ctx, mbid, count) + if err != nil { + return nil, err + } + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + res := make([]agents.Song, len(resp)) + for i, t := range resp { + mbid := "" + if len(t.ArtistMBIDs) > 0 { + mbid = t.ArtistMBIDs[0] + } + + res[i] = agents.Song{ + Album: t.ReleaseName, + AlbumMBID: t.ReleaseMBID, + Artist: t.ArtistName, + ArtistMBID: mbid, + Duration: t.DurationMs, + Name: t.RecordingName, + MBID: t.RecordingMbid, + } + } + return res, nil +} + +func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { + if mbid == "" { + return nil, agents.ErrNotFound + } + + resp, err := l.client.getSimilarArtists(ctx, mbid, limit) + if err != nil { + return nil, err + } + + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + artists := make([]agents.Artist, len(resp)) + for i, artist := range resp { + artists[i] = agents.Artist{ + MBID: artist.MBID, + Name: artist.Name, + } + } + + return artists, nil +} + +func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) { + if mbid == "" { + return nil, agents.ErrNotFound + } + + resp, err := l.client.getSimilarRecordings(ctx, mbid, limit) + if err != nil { + return nil, err + } + + if len(resp) == 0 { + return nil, agents.ErrNotFound + } + + songs := make([]agents.Song, len(resp)) + for i, song := range resp { + songs[i] = agents.Song{ + Album: song.ReleaseName, + AlbumMBID: song.ReleaseMBID, + Artist: song.Artist, + MBID: song.MBID, + Name: song.Name, + } + } + + return songs, nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { - return listenBrainzConstructor(ds) + // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil) + // See https://go.dev/doc/faq#nil_error + a := listenBrainzConstructor(ds) + if a != nil { + return a + } + return nil + }) + + agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface { + // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil) + // See https://go.dev/doc/faq#nil_error + a := listenBrainzConstructor(ds) + if a != nil { + return a + } + return nil }) } }) } + +var ( + _ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil) + _ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil) + _ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil) + _ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil) +) diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index e99b442de..df70ec9c4 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -4,11 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" + "os" "time" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() { Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) }) + + Describe("GetArtistUrl", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns artist url when MBID present", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/")) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + }) + + It("returns error when url not present", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) + }) + + It("returns error when fetch calls fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) + }) + + It("returns error when ListenBrainz returns an error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), + StatusCode: 400, + } + _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) + }) + }) + + Describe("GetTopSongs", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)), + StatusCode: 400, + } + _, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1")) + }) + + It("returns all tracks when asked", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]agents.Song{ + { + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, + }, + { + ID: "", + Name: "String Theocracy", + MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "String Theocracy", + AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + Duration: 174000, + }, + })) + }) + + It("returns only one track when prompted", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]agents.Song{ + { + ID: "", + Name: "world.execute(me);", + MBID: "9980309d-3480-4e7e-89ce-fce971a452be", + Artist: "Mili", + ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + Album: "Miracle Milk", + AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + Duration: 211912, + }, + })) + }) + }) + + Describe("GetSimilarArtists", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=" + mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50" + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := agent.GetSimilarArtists(ctx, "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + }) + + It("returns all data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"}, + })) + }) + + It("returns subset of data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, + })) + }) + }) + + Describe("GetSimilarTracks", func() { + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae" + baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids=" + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("BASE_URL", httpClient) + agent = listenBrainzConstructor(ds) + agent.client = client + }) + + It("returns error when fetch calls", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + }) + + It("returns an error on listenbrainz error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + }) + + It("returns all data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Song{ + { + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artist: "a‐ha", + ArtistMBID: "", + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, + }, + { + ID: "", + Name: "Wake Me Up Before You Go‐Go", + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + ISRC: "", + Artist: "Wham!", + ArtistMBID: "", + Album: "Make It Big", + AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", + Duration: 0, + }, + })) + }) + + It("returns subset of data on call", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) + Expect(resp).To(Equal([]agents.Song{ + { + ID: "", + Name: "Take On Me", + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + ISRC: "", + Artist: "a‐ha", + ArtistMBID: "", + Album: "Hunting High and Low", + AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Duration: 0, + }, + })) + }) + }) }) diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 168aad549..76e47cdc8 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -2,16 +2,29 @@ package listenbrainz import ( "bytes" + "cmp" "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" "path" + "slices" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" ) +const ( + lbzApiUrl = "https://api.listenbrainz.org/1/" + labsBase = "https://labs.api.listenbrainz.org/" +) + +var ( + ErrorNotFound = errors.New("listenbrainz: not found") +) + type listenBrainzError struct { Code int Message string @@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain r := &listenBrainzRequest{ ApiKey: apiKey, } - response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r) + response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r) if err != nil { return nil, err } @@ -104,7 +117,7 @@ func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenI }, } - resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r) if err != nil { return err } @@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err Payload: []listenInfo{li}, }, } - resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r) if err != nil { return err } @@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) { return u.String(), nil } -func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { +func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { b, _ := json.Marshal(r.Body) uri, err := c.path(endpoint) if err != nil { @@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string return &response, nil } + +type lbzHttpError struct { + Code int `json:"code"` + Error string `json:"error"` +} + +func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) { + req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = params.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + // On a 200 code, there is no code. Decode using using error message if it exists + if resp.StatusCode != 200 { + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var lbzError lbzHttpError + jsonErr := decoder.Decode(&lbzError) + + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error} + } + + return resp, err +} + +type artistMetadataResult struct { + Rels struct { + OfficialHomepage string `json:"official homepage,omitempty"` + } `json:"rels,omitzero"` +} + +func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) { + params := url.Values{} + params.Add("artist_mbids", mbid) + resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params) + if err != nil { + return "", err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response []artistMetadataResult + jsonErr := decoder.Decode(&response) + if jsonErr != nil { + return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + if len(response) == 0 || response[0].Rels.OfficialHomepage == "" { + return "", ErrorNotFound + } + + return response[0].Rels.OfficialHomepage, nil +} + +type trackInfo struct { + ArtistName string `json:"artist_name"` + ArtistMBIDs []string `json:"artist_mbids"` + DurationMs uint32 `json:"length"` + RecordingName string `json:"recording_name"` + RecordingMbid string `json:"recording_mbid"` + ReleaseName string `json:"release_name"` + ReleaseMBID string `json:"release_mbid"` +} + +func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) { + resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{}) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response []trackInfo + jsonErr := decoder.Decode(&response) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + if len(response) > count { + return response[0:count], nil + } + + return response, nil +} + +type artist struct { + MBID string `json:"artist_mbid"` + Name string `json:"name"` + Score int `json:"score"` +} + +func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = url.Values{ + "artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm}, + }.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var artists []artist + jsonErr := decoder.Decode(&artists) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + if len(artists) > limit { + return artists[:limit], nil + } + + return artists, nil +} + +type recording struct { + MBID string `json:"recording_mbid"` + Name string `json:"recording_name"` + Artist string `json:"artist_credit_name"` + ReleaseName string `json:"release_name"` + ReleaseMBID string `json:"release_mbid"` + Score int `json:"score"` +} + +func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + req.URL.RawQuery = url.Values{ + "recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm}, + }.Encode() + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var recordings []recording + jsonErr := decoder.Decode(&recordings) + if jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + + // For whatever reason, labs API isn't guaranteed to give results in the proper order + // and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json + // generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30 + slices.SortFunc(recordings, func(a, b recording) int { + return cmp.Or( + cmp.Compare(b.Score, a.Score), // Sort by score descending + cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates + ) + }) + + recordings = slices.CompactFunc(recordings, func(a, b recording) bool { + return a.MBID == b.MBID + }) + + if len(recordings) > limit { + return recordings[:limit], nil + } + + return recordings, nil +} diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 680a7d185..319cf01ab 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,10 +4,13 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "os" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -117,4 +120,345 @@ var _ = Describe("client", func() { }) }) }) + + Context("getArtistUrl", func() { + baseUrl := "https://api.listenbrainz.org/1/metadata/artist?" + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), + StatusCode: 400, + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid.")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles a malformed request without meaningful body", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(``)), + StatusCode: 501, + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns not found when the artist has no official homepage", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973") + Expect(err.Error()).To(Equal("listenbrainz: not found")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns data when the artist has a homepage", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56") + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(Equal("http://projectmili.com/")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + }) + + Context("getArtistTopSongs", func() { + baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)), + StatusCode: 400, + } + _, err := client.getArtistTopSongs(context.Background(), "1", 50) + Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles a malformed request without standard body", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(``)), + StatusCode: 500, + } + _, err := client.getArtistTopSongs(context.Background(), "1", 1) + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns all tracks when given the opportunity", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]trackInfo{ + { + ArtistName: "Mili", + ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, + DurationMs: 211912, + RecordingName: "world.execute(me);", + RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be", + ReleaseName: "Miracle Milk", + ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + }, + { + ArtistName: "Mili", + ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, + DurationMs: 174000, + RecordingName: "String Theocracy", + RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9", + ReleaseName: "String Theocracy", + ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", + }, + })) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("It returns a subset of tracks when allowed", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]trackInfo{ + { + ArtistName: "Mili", + ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, + DurationMs: 211912, + RecordingName: "world.execute(me);", + RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be", + ReleaseName: "Miracle Milk", + ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", + }, + })) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + }) + + Context("getSimilarArtists", func() { + var algorithm string + + BeforeEach(func() { + algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DeferCleanup(configtest.SetupConfig()) + }) + + getUrl := func(mbid string) string { + return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid) + } + + mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := client.getSimilarArtists(context.Background(), "1", 2) + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1"))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles real data properly", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792}, + })) + }) + + It("truncates data when requested", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, + })) + }) + + It("fetches a different endpoint when algorithm changes", func() { + algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30" + conf.Server.ListenBrainz.ArtistAlgorithm = algorithm + + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarArtists(context.Background(), mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]artist{ + {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, + {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792}, + })) + }) + }) + + Context("getSimilarRecordings", func() { + var algorithm string + + BeforeEach(func() { + algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DeferCleanup(configtest.SetupConfig()) + }) + + getUrl := func(mbid string) string { + return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid) + } + + mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae" + + It("handles a malformed request with status code", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), + StatusCode: 400, + } + _, err := client.getSimilarRecordings(context.Background(), "1", 2) + Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1"))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("handles real data properly", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarRecordings(context.Background(), mbid, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + { + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + Name: "Wake Me Up Before You Go‐Go", + Artist: "Wham!", + ReleaseName: "Make It Big", + ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638", + Score: 65, + }, + })) + }) + + It("truncates data when requested", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarRecordings(context.Background(), mbid, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + })) + }) + + It("properly sorts by score and truncates duplicates", func() { + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + // There are actually 5 items. The dedup should happen FIRST + resp, err := client.getSimilarRecordings(context.Background(), mbid, 4) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + { + MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5", + Name: "Everybody Wants to Rule the World", + Artist: "Tears for Fears", + ReleaseName: "Songs From the Big Chair", + ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597", + Score: 68, + }, + { + MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", + Name: "Wake Me Up Before You Go‐Go", + Artist: "Wham!", + ReleaseName: "Make It Big", + ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638", + Score: 65, + }, + { + MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f", + Name: "Tainted Love", + Artist: "Soft Cell", + ReleaseName: "Non-Stop Erotic Cabaret", + ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1", + Score: 61, + }, + })) + }) + + It("uses a different algorithm when configured", func() { + algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30" + conf.Server.ListenBrainz.TrackAlgorithm = algorithm + + f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + resp, err := client.getSimilarRecordings(context.Background(), mbid, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + Expect(resp).To(Equal([]recording{ + { + MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", + Name: "Take On Me", + Artist: "a‐ha", + ReleaseName: "Hunting High and Low", + ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", + Score: 124, + }, + })) + }) + }) }) diff --git a/conf/configuration.go b/conf/configuration.go index ad7bb2d0b..3ed1d653d 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -194,8 +194,10 @@ type deezerOptions struct { } type listenBrainzOptions struct { - Enabled bool - BaseURL string + Enabled bool + BaseURL string + ArtistAlgorithm string + TrackAlgorithm string } type httpHeaderOptions struct { @@ -656,7 +658,9 @@ func setViperDefaults() { viper.SetDefault("deezer.enabled", true) viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) viper.SetDefault("listenbrainz.enabled", true) - viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") + viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) + viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) + viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) viper.SetDefault("enablescrobblehistory", true) viper.SetDefault("httpheaders.frameoptions", "DENY") viper.SetDefault("backup.path", "") diff --git a/consts/consts.go b/consts/consts.go index 583df5c91..eb10fdc03 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -74,6 +74,10 @@ const ( DefaultHttpClientTimeOut = 10 * time.Second + DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/" + DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" + DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') diff --git a/tests/fixtures/listenbrainz.artist.metadata.homepage.json b/tests/fixtures/listenbrainz.artist.metadata.homepage.json new file mode 100644 index 000000000..be15ea45f --- /dev/null +++ b/tests/fixtures/listenbrainz.artist.metadata.homepage.json @@ -0,0 +1,19 @@ +[ + { + "area": "Japan", + "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "begin_year": 2012, + "mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "name": "Mili", + "rels": { + "free streaming": "https://www.deezer.com/artist/56563392", + "official homepage": "http://projectmili.com/", + "purchase for download": "https://recochoku.jp/artist/2000285803/", + "social network": "https://www.instagram.com/projectmili/", + "streaming": "https://tidal.com/artist/3848902", + "wikidata": "https://www.wikidata.org/wiki/Q27309228", + "youtube": "https://www.youtube.com/channel/UCVh47EKH9VLresRqiYi9txw" + }, + "type": "Group" + } +] diff --git a/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json b/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json new file mode 100644 index 000000000..6105556a2 --- /dev/null +++ b/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json @@ -0,0 +1,15 @@ +[ + { + "area": "Japan", + "artist_mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973", + "mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973", + "name": "Feryquitous", + "rels": { + "free streaming": "https://www.deezer.com/artist/9841008", + "purchase for download": "https://itunes.apple.com/jp/artist/id1083544578", + "social network": "https://twitter.com/Feryquitous_", + "youtube": "https://www.youtube.com/channel/UCj2nw_9puY3sJoDbkE-FCQA" + }, + "type": "Person" + } +] diff --git a/tests/fixtures/listenbrainz.labs.similar-artists.json b/tests/fixtures/listenbrainz.labs.similar-artists.json new file mode 100644 index 000000000..1cd2c85b5 --- /dev/null +++ b/tests/fixtures/listenbrainz.labs.similar-artists.json @@ -0,0 +1 @@ +[{"artist_mbid": "f27ec8db-af05-4f36-916e-3d57f91ecf5e", "name": "Michael Jackson", "comment": "\u201cKing of Pop\u201d", "type": "Person", "gender": "Male", "score": 800, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}, {"artist_mbid": "7364dea6-ca9a-48e3-be01-b44ad0d19897", "name": "a-ha", "comment": "Norwegian synth\u2010pop band", "type": "Group", "gender": null, "score": 792, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json b/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json new file mode 100644 index 000000000..61913bcf5 --- /dev/null +++ b/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json @@ -0,0 +1 @@ +[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You Go‐Go","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"ef4c6855-949e-4e22-b41e-8e0a2d372d5f","recording_name":"Tainted Love","artist_credit_name":"Soft Cell","artist_credit_mbids":null,"release_name":"Non-Stop Erotic Cabaret","release_mbid":"1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1","caa_id":1031647403,"caa_release_mbid":"c3367d3a-2f6c-48d1-95c5-c1ee7a49c479","score":61,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5","recording_name":"Everybody Wants to Rule the World","artist_credit_name":"Tears for Fears","artist_credit_mbids":null,"release_name":"Songs From the Big Chair","release_mbid":"21f19b06-81f1-347a-add5-5d0c77696597","caa_id":19682986993,"caa_release_mbid":"9aefc6dd-216a-4271-ada1-d9cf67956f39","score":68,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.labs.similar-recordings.json b/tests/fixtures/listenbrainz.labs.similar-recordings.json new file mode 100644 index 000000000..87323f426 --- /dev/null +++ b/tests/fixtures/listenbrainz.labs.similar-recordings.json @@ -0,0 +1 @@ +[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You Go‐Go","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.popularity.json b/tests/fixtures/listenbrainz.popularity.json new file mode 100644 index 000000000..ea459b120 --- /dev/null +++ b/tests/fixtures/listenbrainz.popularity.json @@ -0,0 +1,81 @@ +[ + { + "artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"], + "artist_name": "Mili", + "artists": [ + { + "artist_credit_name": "Mili", + "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "join_phrase": "" + } + ], + "caa_id": 14987576054, + "caa_release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408", + "length": 211912, + "recording_mbid": "9980309d-3480-4e7e-89ce-fce971a452be", + "recording_name": "world.execute(me);", + "release_color": { "blue": 109, "green": 94, "red": 95 }, + "release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408", + "release_name": "Miracle Milk", + "tags": [ + { + "count": 1, + "genre_mbid": "911c7bbb-172d-4df8-9478-dbff4296e791", + "tag": "pop" + }, + { + "count": 1, + "genre_mbid": "b739a895-85ed-4ad3-8717-4e9ef5387dd8", + "tag": "dance-pop" + }, + { + "count": 1, + "genre_mbid": "9c8ba153-740e-4b88-b7ff-31d004944c95", + "tag": "nerdcore" + }, + { + "count": 1, + "genre_mbid": "c4a69842-f891-4569-9506-1882aa5db433", + "tag": "electronic rock" + }, + { "count": 1, "tag": "hackercore" }, + { "count": 1, "tag": "meter:4/4" }, + { "count": 1, "tag": "vocal:true" }, + { "count": 1, "tag": "bpm:130" }, + { + "count": 1, + "genre_mbid": "e5bba957-8c91-496a-a675-c6d0c6b51c33", + "tag": "dance" + }, + { + "count": 1, + "genre_mbid": "89255676-1f14-4dd8-bbad-fca839d6aff4", + "tag": "electronic" + } + ], + "total_listen_count": 19440, + "total_user_count": 1102 + }, + { + "artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"], + "artist_name": "Mili", + "artists": [ + { + "artist_credit_name": "Mili", + "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", + "join_phrase": "" + } + ], + "caa_id": 31388973421, + "caa_release_mbid": "e58ed9ef-2bc1-4480-9d6d-2d799beb5ba9", + "length": 174000, + "recording_mbid": "afa2c83d-b17f-4029-b9da-790ea9250cf9", + "recording_name": "String Theocracy", + "release_color": { "blue": 92, "green": 147, "red": 164 }, + "release_mbid": "d79a38e3-7016-4f39-a31a-f495ce914b8e", + "release_name": "String Theocracy", + "tags": [], + "total_listen_count": 8986, + "total_user_count": 712 + } +]