mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(agents): Add artist url and top songs to ListenBrainz agent
This commit is contained in:
parent
4d4740b83b
commit
c66dcc21eb
@ -118,12 +118,65 @@ 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
|
||||
}
|
||||
|
||||
var res []agents.Song
|
||||
for _, t := range resp {
|
||||
res = append(res, agents.Song{
|
||||
Name: t.RecordingName,
|
||||
MBID: t.RecordingMbid,
|
||||
})
|
||||
}
|
||||
return res, 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 (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := listenBrainzConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if conf.Server.ListenBrainz.EnableMetadata {
|
||||
if conf.Server.ListenBrainz.BaseURL != "https://api.listenbrainz.org/1/" {
|
||||
log.Warn("ListenBrainz is enabled with a nonstandard API endpoint. Requests will likely not work")
|
||||
}
|
||||
|
||||
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 (*lastfmAgent)(nil)
|
||||
// See https://go.dev/doc/faq#nil_error
|
||||
a := listenBrainzConstructor(ds)
|
||||
if a != nil {
|
||||
return a
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -4,8 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
@ -162,4 +164,51 @@ 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", 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 fListenBrainz 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@ -12,6 +13,10 @@ import (
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrorNotFound = errors.New("listenbrainz: not found")
|
||||
)
|
||||
|
||||
type listenBrainzError struct {
|
||||
Code int
|
||||
Message string
|
||||
@ -88,7 +93,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 +109,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 +127,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 +146,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 +182,99 @@ 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) {
|
||||
uri, err := c.path(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequestWithContext(ctx, method, uri, 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 resp.StatusCode != 200 && 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 {
|
||||
RecordingName string `json:"recording_name"`
|
||||
RecordingMbid string `json:"recording_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
|
||||
}
|
||||
|
||||
@ -117,4 +117,113 @@ var _ = Describe("client", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("getArtistUrl", func() {
|
||||
baseUrl := "BASE_URL/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 := "BASE_URL/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{
|
||||
{RecordingName: "world.execute(me);", RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be"},
|
||||
{RecordingName: "String Theocracy", RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9"},
|
||||
}))
|
||||
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{
|
||||
{RecordingName: "world.execute(me);", RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be"},
|
||||
}))
|
||||
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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -187,8 +187,9 @@ type deezerOptions struct {
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
Enabled bool
|
||||
EnableMetadata bool
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type httpHeaderOptions struct {
|
||||
@ -622,6 +623,7 @@ func setViperDefaults() {
|
||||
viper.SetDefault("deezer.enabled", true)
|
||||
viper.SetDefault("deezer.language", "en")
|
||||
viper.SetDefault("listenbrainz.enabled", true)
|
||||
viper.SetDefault("listenbrainz.enablemetadata", true)
|
||||
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
|
||||
19
tests/fixtures/listenbrainz.artist.metadata.homepage.json
vendored
Normal file
19
tests/fixtures/listenbrainz.artist.metadata.homepage.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
15
tests/fixtures/listenbrainz.artist.metadata.no_homepage.json
vendored
Normal file
15
tests/fixtures/listenbrainz.artist.metadata.no_homepage.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
81
tests/fixtures/listenbrainz.popularity.json
vendored
Normal file
81
tests/fixtures/listenbrainz.popularity.json
vendored
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
Loading…
x
Reference in New Issue
Block a user