mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-08 05:51:06 -05:00
Compare commits
3 Commits
transcodin
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
408aa78ed5 | ||
|
|
29f98b889b | ||
|
|
1e37e680d7 |
@@ -49,6 +49,7 @@ func (e extractor) Version() string {
|
|||||||
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||||
f, close, err := e.openFile(filePath)
|
f, close, err := e.openFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer close()
|
defer close()
|
||||||
|
|||||||
@@ -118,12 +118,129 @@ func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) boo
|
|||||||
return err == nil && sk != ""
|
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() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
@@ -162,4 +165,279 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
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,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,16 +2,29 @@ package listenbrainz
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/log"
|
"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 {
|
type listenBrainzError struct {
|
||||||
Code int
|
Code int
|
||||||
Message string
|
Message string
|
||||||
@@ -88,7 +101,7 @@ func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrain
|
|||||||
r := &listenBrainzRequest{
|
r := &listenBrainzRequest{
|
||||||
ApiKey: apiKey,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,7 +135,7 @@ func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) err
|
|||||||
Payload: []listenInfo{li},
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -141,7 +154,7 @@ func (c *client) path(endpoint string) (string, error) {
|
|||||||
return u.String(), nil
|
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)
|
b, _ := json.Marshal(r.Body)
|
||||||
uri, err := c.path(endpoint)
|
uri, err := c.path(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -177,3 +190,189 @@ func (c *client) makeRequest(ctx context.Context, method string, endpoint string
|
|||||||
|
|
||||||
return &response, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "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,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -194,8 +194,10 @@ type deezerOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type listenBrainzOptions struct {
|
type listenBrainzOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
ArtistAlgorithm string
|
||||||
|
TrackAlgorithm string
|
||||||
}
|
}
|
||||||
|
|
||||||
type httpHeaderOptions struct {
|
type httpHeaderOptions struct {
|
||||||
@@ -656,7 +658,9 @@ func setViperDefaults() {
|
|||||||
viper.SetDefault("deezer.enabled", true)
|
viper.SetDefault("deezer.enabled", true)
|
||||||
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
viper.SetDefault("deezer.language", consts.DefaultInfoLanguage)
|
||||||
viper.SetDefault("listenbrainz.enabled", true)
|
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("enablescrobblehistory", true)
|
||||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||||
viper.SetDefault("backup.path", "")
|
viper.SetDefault("backup.path", "")
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ const (
|
|||||||
|
|
||||||
DefaultHttpClientTimeOut = 10 * time.Second
|
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"
|
DefaultScannerExtractor = "taglib"
|
||||||
DefaultWatcherWait = 5 * time.Second
|
DefaultWatcherWait = 5 * time.Second
|
||||||
Zwsp = string('\u200b')
|
Zwsp = string('\u200b')
|
||||||
|
|||||||
12
go.mod
12
go.mod
@@ -14,7 +14,7 @@ require (
|
|||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||||
github.com/andybalholm/cascadia v1.3.3
|
github.com/andybalholm/cascadia v1.3.3
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.2
|
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
github.com/bradleyjkemp/cupaloy/v2 v2.8.0
|
||||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf
|
||||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55
|
||||||
@@ -28,7 +28,7 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1
|
github.com/dustin/go-humanize v1.0.1
|
||||||
github.com/extism/go-sdk v1.7.1
|
github.com/extism/go-sdk v1.7.1
|
||||||
github.com/fatih/structs v1.1.0
|
github.com/fatih/structs v1.1.0
|
||||||
github.com/go-chi/chi/v5 v5.2.4
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/go-chi/httprate v0.15.0
|
github.com/go-chi/httprate v0.15.0
|
||||||
github.com/go-chi/jwtauth/v5 v5.3.3
|
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||||
@@ -49,8 +49,8 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.33
|
github.com/mattn/go-sqlite3 v1.14.33
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/onsi/ginkgo/v2 v2.27.5
|
github.com/onsi/ginkgo/v2 v2.28.1
|
||||||
github.com/onsi/gomega v1.39.0
|
github.com/onsi/gomega v1.39.1
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4
|
github.com/pelletier/go-toml/v2 v2.2.4
|
||||||
github.com/pocketbase/dbx v1.11.0
|
github.com/pocketbase/dbx v1.11.0
|
||||||
github.com/pressly/goose/v3 v3.26.0
|
github.com/pressly/goose/v3 v3.26.0
|
||||||
@@ -98,7 +98,7 @@ require (
|
|||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect
|
||||||
github.com/google/subcommands v1.2.0 // indirect
|
github.com/google/subcommands v1.2.0 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
@@ -134,7 +134,7 @@ require (
|
|||||||
github.com/stretchr/objx v0.5.3 // indirect
|
github.com/stretchr/objx v0.5.3 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
|
|||||||
24
go.sum
24
go.sum
@@ -16,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
|||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.2 h1:b0mc6WyRSYLjzofB2v/0cuDUZ+MqoGyH3r0dVij35GI=
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M=
|
||||||
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0=
|
||||||
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk=
|
||||||
@@ -77,8 +77,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ
|
|||||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||||
github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
|
||||||
@@ -110,8 +110,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
|
||||||
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno=
|
||||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -197,10 +197,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
|
|||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750=
|
||||||
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
@@ -301,8 +301,8 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
1
tests/fixtures/listenbrainz.labs.similar-artists.json
vendored
Normal file
1
tests/fixtures/listenbrainz.labs.similar-artists.json
vendored
Normal file
@@ -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"}]
|
||||||
1
tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json
vendored
Normal file
1
tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json
vendored
Normal file
@@ -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"}]
|
||||||
1
tests/fixtures/listenbrainz.labs.similar-recordings.json
vendored
Normal file
1
tests/fixtures/listenbrainz.labs.similar-recordings.json
vendored
Normal file
@@ -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"}]
|
||||||
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
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user